PLAN v3 · agent-smith · 2026-08-15

Smith PR-Drive v3 — the task-workflow seam

Supersedes v2. v2's design is unchanged and approved for build — v3 is purely additive: it names the generalization seam (§12), scopes the eval-👎 self-healing loop (§13), and reconciles the held-back core-loop fix from the Reddit thread (§14). Repo: PropFlow-Technologies/agent-smith · future home docs/planning/smith-pr-drive-durable.md

Founder decision (binding, v2): "Temporal is the backstop? Make Temporal the MAIN DRIVER instead of fighting it or having two drivers."

Founder direction (v3): "should it be started when I kick off a task… so it should be a task workflow… I just wanna make sure we are not kind of stepping on the toes of an existing workflow" → §12: design the seam, build only the PR drive.

Founder authorization (v3): "Yes drive all the work and merge when done."
What changed from v2

Nothing was removed and nothing was redesigned. Sections 0–11 are v2 as written and as approved. Three sections were added:

0 · The incident, and what changed since v1

2026-08-14: Smith opened propflowai #5749, said "I'll drive it to mergeable", and ended the turn without running smith-pr-watch add 5749 — the one manual step binding the GitHub webhook to the thread. The bot's 🟡 verdict landed into a dead session; five hours of silence until the human asked why.

v1's answer kept the webhook as the primary driver and added a Temporal backstop — a 10-minute reconcile tick — plus triple auto-arm belts and dedup machinery so the two drivers wouldn't trip over each other.

v2's answer (the founder's): there is one driver, and it is the durable one. SmithPrDriveWorkflow owns the loop: a durable timer (~45s while the PR is active), GitHub truth via the already-registered merge_gate_activity — the exact instrument SmithApprovalWorkflow already polls (approval.py:76,88) — a state fingerprint, and exactly one reasoning turn (claude -p via the synthetic-inbound path) when something actionable changed. The GitHub webhook is removed from Smith's drive path entirely.

Failure modev1's treatmentv2's treatment
Manual arming (the incident)3 auto-arm belts + CLI fallbackOpening the PR is the arming: the reply-workflow end-of-turn hook starts the drive off runner-observed gh pr create evidence. One belt, one CLI fallback.
Webhook listener as a liveness dependency (launchd + tunnel + Node)Backstop catches a dark listener after ~25 minNo listener in the path. The only liveness dependency is the Temporal worker — the component the agent already lives or dies by.
Double-wake racenote_event stall resets + movement suppression + "worst case one redundant turn"Cannot occur. One driver, one wake decision point. The dedup machinery is deleted, not hardened.

Accepted trade-off, precisely: reaction latency becomes one poll tick (~45s worst case vs ~2s webhook) — irrelevant against the 5-hour silent failure this fixes. Cost: merge_gate_activity issues ≤3 gh requests per call (merge_gate.py:116,87,184). At a 45s tick: ≤80 polls/hr → ≤240 requests/hr per open drive; five concurrent drives ≈ 1,200/hr ≈ 24% of the 5,000/hr authenticated REST budget (the GraphQL leg draws ~80 points/hr on its separate budget — noise). For calibration, the approval workflow's existing _GATE_POLL is already 15s during gating.

Merge stays human-gated, untouched. The drive never merges. A woken turn that reaches green announces Mergeable:; start_approval_if_mergeable starts the untouched SmithApprovalWorkflow; a human ✅ approves; the approval merges. The drive observes MERGED on its next tick and completes.

In plain terms

Before: Smith had to remember to flip a switch so GitHub could tap it on the shoulder — and the tap traveled through four fragile hops. Now a permanent, crash-proof helper simply checks the PR every 45 seconds and taps Smith itself. Nothing to remember, nothing to keep alive except the one engine everything already runs on. A person still clicks the final approve before anything merges.

1 · What v1 got wrong — owned explicitly

v1 treated the webhook as the asset and Temporal as the insurance. That inverted the actual reliability ordering: the webhook path is a chain of five single-points-of-failure (GitHub delivery → cloudflared tunnel → launchd → Node listener → watch-file registry), while the Temporal worker is the one component whose liveness the agent already depends on and monitors. v1 then spent its complexity budget making two drivers coexist instead of asking whether the second driver should exist. The founder caught it: approval.py had already proven the pattern (a durable timer polling merge_gate_activity for up to 24h per PR); the drive is the same loop on the pre-mergeable half of the PR's life, and the webhook adds a ~43-second latency improvement priced at three failure modes.

v1 elementFateWhy deleting it is a win
§2.4 third belt — pr_event signal-with-start on webhook eventsDeletedNo webhook events in the path; the belt guarded a seam that no longer exists.
note_event as dedup / stall-reset between two driversDeletedOne driver needs no inter-driver coordination. (A poke signal survives as a future latency-hint seam only.)
"Fingerprint movement = someone is acting → suppress wake"DeletedExisted to avoid stepping on webhook-woken turns. In v2 movement is the wake trigger; one-wake-per-fingerprint is the entire dedup story.
pr_watch.py registry kept as "the webhook router" (v1 §6)RetiredBoth its jobs are obsolete: no listener to route for, and the thread binding rides the drive's durable PrDriveRequest. ~230 lines + lazy expiry + corrupt-file handling, gone.
pr_event.py as the Node→Temporal bridgeRe-scopedcompose_text / build_trigger / record_phase / anchor resolution are exactly what the wake activity needs; the CLI entry and bridge role retire.
10-min backstop tick + 15-min stall as the reaction path when webhooks are darkReplaced~25-min worst case becomes ~45s, always — no "is the webhook up?" bifurcation. The stall window survives only for genuinely-pending states.
v1 PR-1 "auto-arm the shim" (runner writes watch files)ReshapedThe detection (pr_open_detect.py) survives unchanged; what it arms is now the single driver workflow.

What survives from v1 unchanged: the pr_open_detect.py pure parser and its mentioned-vs-opened discipline; the decision against extending SmithTaskWatchWorkflow (wrong keying, wrong caps, and its "disarmed of real-send risk by construction" safety constitution, task_watch.py:36-43, which a turn-spawning drive would delete); the approval-precedent match; the wake path through dispatcher.dispatch(); one-wake-per-fingerprint + escalation + budget; the replay-safety reasoning for the reply hook.

2 · Constitution fit (§1 DRY, §2 precedents)

ConcernReused mechanism
PR state probemerge_gate_activity (activities/merge_gate.py:112) — registered, returns ok / recoverable / reason / head_sha / pr_state / unresolved_threads / verdict_is_stale. The named durable-poll precedent is approval.py_GATE_POLL (15s) and _READY_POLL (120s). No new gh poller.
Acting on statethe synthetic-inbound path — pr_event.compose_textbuild_triggerdispatcher.dispatch() (start-or-signals one reply workflow per thread). Extracted into pr_event.wake(...).
Arming idempotencyWorkflowAlreadyStartedError → no-op, verbatim start_approval_if_mergeable (approval.py:693).
Child lifecycleABANDON child, id-keyed per PR — verbatim the approval child pattern (approval.py:671-691).
Long-run history safetyis_continue_as_new_suggested() + carried-state resume — the reply.py CAN-belt precedent (reply.py:148-179, 1005-1012).
Determinism rulesno config import; workflow-local timedeltas; all I/O in activities; imports_passed_through().
Status surfacenone new — woken turns ride pr_live_status + the phase table; the drive posts ≤ a handful of one-liners over its life.

3 · SmithPrDriveWorkflow — the driver

3.1 Identity & types

3.2 Signals / query

NameEffect
stop (signal)operator off-switch — drive completes "stopped"; wired to smith-pr-drive stop.
poke (signal)"look now" latency hint — the only future role a webhook may ever have. Wakes the pending tick early so the next gate read fires immediately. Carries no state, resets no clocks, dedups nothing — a missed or duplicated poke changes only latency. Nothing sends it in v2; it ships because retrofitting a signal onto a live class later costs deploy coordination, while an unused handler is free.
status (query)phase state=<cls> ticks=… wakes=… fp=… — mirrors task_watch.progress.

3.3 Constants (workflow-local; founder question 1)

_TICK            = timedelta(seconds=45)   # primary cadence while the current fp is un-acted
_IDLE_TICK       = timedelta(minutes=5)    # after this fp was woken/escalated — waiting on an
                                           # external actor (Smith's turn, the human ✅); any
                                           # fp change restores _TICK
_STALL_AFTER     = timedelta(minutes=15)   # how long a WAIT-state may sit unchanged before it
                                           # is treated as stalled (dead review, hung CI)
_MAX_WAKES       = 12                      # absolute wake ceiling per drive
_DRIVE_LIFETIME  = timedelta(hours=36)     # > approval's 24h window; then one honest expiry line
_DEAD_REVIEW_HINT = "If no bot verdict exists for the current head, re-trigger it:
  `gh workflow run claude-code-review.yml -f pr_number=<N>` (the #alerts playbook)."

The idle back-off is approval's _READY_WATCH_CEILING instinct (approval.py:90-95) expressed as a two-speed tick instead of a stop: the drive must never stop looking (terminal detection is its job), but it has no business polling at 45s while a human sleeps on an ✅.

3.4 State classification (pure, unit-tested)

_classify(gate) → class replaces both v1's _actionable() heuristic and its raw-reason fingerprint (raw reasons carry volatile text like exit=255 that would churn the fingerprint). Fingerprint: cls|head_sha|unresolved_threads.

ClassGate evidence (merge_gate.py)Wake policy
MERGED / CLOSEDpr_state (:140)terminal (§3.6)
MERGEABLEgate.ok (:225)wake — announce Mergeable: (starts the approval child)
NEEDS_FIXESblocking verdict 🟡/🔴/📝 (:228)wake — the incident case
NEEDS_THREADSunresolved_threads > 0 (:171)wake — resolve review threads
STALE_VERDICTverdict_is_stale (:221)wake — re-trigger the bot on the head
CI_FAILEDcheck failed (:151)wake — fix the red check
CONFLICTSCONFLICTING (:143)wake — rebase
AWAITING_VERDICT"no claude-bot review verdict yet" (:201)wait; unchanged past stall → wake + dead-review hint
CI_RUNNINGcheck pending (:156)wait; unchanged past stall → wake ("CI appears hung")
GH_ERRORgh/network failure (:125)wait; unchanged past stall → wake ("can't read PR state") — self-heals on recovery

3.5 The loop

deadline = now + (remaining_seconds or _DRIVE_LIFETIME)
wakes, woken, escalated = carried-from-request; last_fp = None; fp_since = now
while workflow.now() < deadline and not stopped:
    tick = _IDLE_TICK if last_fp in woken else _TICK
    wait_condition(stopped or poked, timeout=tick)        # durable timer, early-wake on signal
    gate = merge_gate_activity(repo, pr)                  # ≤3 gh reads — the ONE probe
    cls  = _classify(gate)
    if cls in (MERGED, CLOSED): return finish(cls)        # §3.6
    fp = f"{cls}|{head_sha}|{unresolved}"
    if fp != last_fp:
        last_fp, fp_since = fp, now
        if not _wakes_immediately(cls): continue          # WAIT-state: give it the stall window
    else:
        if not _wakes_immediately(cls) and now - fp_since < _STALL_AFTER: continue
        if fp in woken:
            escalate once per fp ("still stuck — needs a human"), then silence
            continue
    if wakes >= _MAX_WAKES: post budget line; return "wake_budget_exhausted"
    pr_drive_wake_activity(repo, pr, "reconcile", summary(cls, gate), binding)
    wakes += 1; woken.add(fp)
    if is_continue_as_new_suggested(): continue_as_new(carried state)
post expiry line; return "expired"

3.6 Terminal transitions

ObservationBehavior
MERGEDone final wake (kind="merged" — preserves today's post-merge wrap-up turn) → pr_drive_finalize_activity (stamp merged_at on the phase row; clear thread anchors — the pr_event.py:234-243 merged-path behavior) → "merged".
MERGED on the first ticksilent finalize, no wake, no post — never announce a completion that predates the request (the task_watch already_done rule).
CLOSEDone honest line, finalize, "closed". Improvement: today a closed PR leaked its watch file for 3 days (pr_watch.py:95); that leak class is deleted with the registry.
stop signal"stopped", finalize without stamping.
budget / lifetimeone honest line each; deliberate CLI re-arm starts fresh under the same id.

3.7 Activities (new activities/pr_drive.py)

4 · Auto-start — opening the PR arms the driver

  1. Detection — pr_open_detect.py (new, pure): parse the turn's stream-json log for a Bash tool_use containing gh pr create paired by tool_use_id with a URL-bearing tool_result → deduped (repo, pr). Fires on PRs opened this turn, never merely mentioned. Precedent: audit_session_activity already post-scans this log.
  2. claude_runner_activity populates ClaudeReply.opened_prs (additive-defaulted). No watch-file writes — the activity's output is evidence, the workflow acts on it.
  3. Reply-workflow end-of-turn hookstart_pr_drive_for_opened(...) beside the existing approval + task-watch hooks (reply.py:~1301, ~1358). ABANDON child per opened PR; WorkflowAlreadyStartedError → no-op. Replay-safe without a patch gate by the abstain precedent (reply.py:1263-1276): the trigger is an additive-defaulted activity-result field, so every pre-deploy history replays opened_prs=[] → zero commands.
  4. CLI fallback — smith-pr-drive (replaces smith-pr-watch): add binds from the runner-injected SMITH_CONV_* env; stop signals; list; adopt (migration). Stays in prompts.py for creates the detector can't see — but the prompt now says the drive auto-starts.

The incident replay under v2: Smith opens #5749 and ends the turn having run nothing. The runner logged the create; the hook starts the drive; the first tick sees CI_RUNNING and waits; when the 🟡 lands, the next tick classifies NEEDS_FIXES — new fingerprint, immediate wake — and Smith is working the verdict within ~1 minute of it posting. No human, no CLI, no listener.

5 · What happens to the webhook plumbing

ComponentDecisionReasoning + migration
pr_watch.py (registry + CLI)Retire (delete), PR-2Both jobs obsolete: no listener to route for; the binding rides PrDriveRequest. Keeping it would be the "second store" v1's own DRY table warned about.
pr_event.pyRe-scope, PR-2Keep compose_text / build_trigger / record_phase / anchor helpers — now called by the wake activity, binding as arguments. Delete main() + argparse.
listener.js Smith branch (propflowai)Inert in PR-2 → deleted in a follow-up PRThe branch gates on smithWatchExists() (listener.js:399). No watch files → structurally dead, with zero cross-repo deploy coordination. The /review-turns consumer (listener.js:669) is untouched. Do the cleanup PR within a week — an inert branch that reads plausibly is how the second driver gets re-armed by accident.
Webhook as a latency hintNot built; seam reservedIf ~45s ever matters, re-point the listener at the poke signal (~2s reaction) — a hint into the single driver, structurally incapable of being a second one. Default: 45s is fine (founder question 4).
launchd webhook job + cloudflared tunnelOut of Smith's drive pathThey remain for other consumers. Smith's drive reliability = the Temporal worker's reliability, full stop.

6 · File-level change list

#FileChange
1pr_open_detect.py (new)Pure stream-json parser (v1 §2.1 verbatim; reuses phase_table.PR_URL_RE).
2activities/claude_runner.pyPopulate ClaudeReply.opened_prs post-run (fail-soft). No watch-file writes.
3types.pyopened_prs; PrDriveRequest / PrDriveWakeInput / PrDriveFinalizeInput.
4workflows/pr_drive.py (new)The workflow + pure helpers + start_pr_drive_for_opened + id helper.
5activities/pr_drive.py (new)pr_drive_wake_activity, pr_drive_finalize_activity.
6pr_event.pyExtract wake(...); binding as args; delete main().
7pr_watch.pyDeleted (PR-2, after adopt).
8pr_drive_cli.py (new)smith-pr-drive add/stop/list/adopt console script.
9agent_ident.pyPR_DRIVE_WF_ID_PREFIX.
10worker.pyRegister the workflow + both activities.
11workflows/reply.pyThe auto-start hook, with the abstain-precedent comment.
12workflows/task_watch.pyDocstring only: the webhook-gap sentence now points at the drive.
13prompts.pyAuto-start noted; smith-pr-drive add as fallback; smith-pr-watch ritual removed.
14docs/planning/smith-pr-drive-durable.md (new)This document.
15propflowai listener.js (follow-up PR)Delete the inert Smith branch; keep /review-turns.

No changes to: approval.py, dispatcher.py, pr_live_status.py, thread_phase.py, thread_anchor.py, watch_intent.py.

7 · Failure-mode table (single-driver semantics)

FailureDetectionBehaviorRecovery
Smith forgets to arm (the incident)Cannot occurArming is the reply hook off runner-observed evidence, not a brain action
GitHub webhook infra darkNot in the pathNo effect on drives
Temporal worker down / deploy restartTask-timeout + replay on returnTimers/signals durable; drives resume. The single liveness dependency — the one every Smith function already has. v1 had this plus the listener chain.None needed
gh outage / rate-limitGH_ERROR classWaits through the stall window (transients self-heal invisibly); persistent outage → one honest wake, one escalation, then silence on that fpfp changes when gh recovers
Dead reviewAWAITING_VERDICT unchanged past stallWake carries the dead-review playbook hint; the brain runs the re-triggerVerdict lands → new fp. Boundary with the repo watchdog: §8.4
CI hangsCI_RUNNING unchanged past stallOne wake ("CI appears hung")fp moves when CI settles
Woken turn crashes mid-fixfp doesn't moveOne escalation line ("still stuck — needs a human")Any push moves the fp and re-opens the cycle
Duplicate arming (hook + CLI)WorkflowAlreadyStartedErrorNo-op — one drive per PR by id
Armed on an already-merged PRFirst tick → MERGEDSilent finalize
Closed without mergeCLOSEDOne honest line; the 3-day watch-file leak class is deleted
Wake budget / lifetimecounter / deadlineOne honest line eachDeliberate re-arm starts fresh
History growthis_continue_as_new_suggested() + heartbeat checkCAN carrying budget + dedup state
Slack send refusednon-retryableLogged; drive continues
Wake latency≤45s active / ≤5m idle-fp — the accepted trade-offpoke seam if it ever matters

8 · Pattern-adoption survey — who else needs a durable driver

The pattern, named: a long-lived Temporal workflow owns the loop — durable timer, polls the source of truth through an activity, spawns an ephemeral reasoning turn only when something actionable changed, carries dedup/budget/binding in workflow history instead of files, preserves human gates as signals. The failure class it cures: ephemeral brain + bespoke fragile wake plumbing (nohup, detached Popen, launchd one-shots, webhook chains, in-process asyncio.sleep loops) + manual arming + fire-and-forget death with no receipt.

The fleet already contains the pattern done right — daemon_liveness.py and deploy_freshness.py, whose docstring states the principle: "a monitor inside the thing being monitored cannot report its own absence." The nightly/morning queues are durable, continue-on-failure, per-step-alerting — done right (one caveat: a SIGKILL'd subprocess skips its own automation_run exit alert; the queue's synthesized error JobResult still reddens the roll-up).

8.1 Ranked candidates

#CandidateCurrent mechanismFailure class (measured)Adoption shapeEffortRank
1Smith PR drivewebhook chain + manual arm5h silent stall on #5749 (2026-08-14)This planMP0
2maintenance_eval on-demand ("send the eval")nohup … & from a chat turn (prompts.py:98); then a plain asyncio.sleep(30) poll of a DDB pointer row for 40–66 min (maintenance_eval.py:347-447)Orphaned to PID 1 — a reboot, OOM, or the deploy script's own launchctl kickstart -k (batch_guard.py) kills it silently while the operator holds an unfulfilled "posts in ~30 min" promise; automation_run never fires on SIGKILL; documented false-alarm class at :435-447SmithMaintenanceEvalWorkflow: start activity + workflow.sleep pointer-poll + headline post. The durable shape is literally already written, in the wrong runtime. Nightly queue then awaits it as a child, deleting the derived-timeout couplingS/MP1
3Blocked-decision ledger (operators)CF-KV answers + local ledger + an answer bridge that writes answer but never state2026-08-10: 40 answers landed, 28 read unresolved an hour later; humans answer twiceDecisionBlockWorkflow per block — §8.3, the first sliceMP1
4voice-call-reconciler (propflowai)Vercel cron * * * * * — 1,440 runs/day polling Twilio for stuck attempted_pending callsPer-call wait as a per-minute fleet sweep; no give-up, everThe voice workflow races webhookSignal vs sleep(timeout), polls Twilio once on timeout. Deletes the cron — best cost/benefit in the fleetSP1
5alert-remediation episodesDaily one-shot scan → fire-and-forget deciders; episode/noise memory in ~/.claude/smith-state/*.json on one Mac's disk; human ✅ is polled, not awaitedLose smith-state/ → forgets every episode and every human "this is noise" ruling, re-drives the ledgerOne long-lived child per alert episode: ✅ as a signal, durable re-nag, noise rulings as workflow state; the daily scan thins to a dispatcher. git-hygiene-main-drift's escalation ladder folds in hereMP2
6Morpheus detached authoring_spawn_detached (morpheus.py:64-73): 15–20 min pipeline, ack posted first, zero watcher; the validation-activity spawn's before-spawn dedup marker guarantees a dead child is never retriedChild dies → ack stands, proposal never lands, no alert, no retry; evidence only in a log fileMorpheusAuthorWorkflow: heartbeating activity; dedup by workflow id; completion posts the receipt or an honest failureMP2
7renewal-prepare-retry (propflowai)Cron */30 + a hand-rolled retry policy in a DDB singleton (attempts map, cap-plus-one sentinel, slot release()retry-failed-prepare.ts:1217)Built after the 2026-07-02 retry storm; a timed-out retry burned a slot; fresh saga ids re-fired the stuck alert per idTemporal retry policies + per-saga state give all of it for free. Works today — migrate on the next renewal-architecture touchMP3
8Transcript-pipeline receiptThe almost-right pattern (slack_socket.py:376-455): detached spawn plus a watcher — but the watcher is an in-process thread of a KeepAlive daemon; a bounce orphans the ackDaemon restart mid-pipeline = ack with no receipt; dedup already "claimed, not completed"Small workflow with a heartbeating activity; same for the Zoom receiver's delivery threadSP3
9escalation-matter-nagDaily cron over EscalationMatter rowsGranularity: a 15:05 deadline waits ~24h; a missed run extends every deadline undetectedPer-matter durable timer + reply signal = the ADR-0104 cadence it is already slated to become. Defer: ride the production builddefer
10yale-readinessDaily digest + manual-retirement TODONobody is told when the signal flips; the table posts foreverwait_condition(green) → announce → hold 7d stable → completeSP3
11nightly_program bounded-waitQueue-drain ledger + bounded wait for fan-out workers in a plain subprocess; inert behind rollout gatesSame class as #2 at overnight durationWait + ledger into workflow state — before it is armed, not afterMP3
12SmithTaskWatchWorkflowAlready durableKeep; do not subsume. Its 2h cap and narrate-only scope are safety properties (task_watch.py:36-43). The drive takes over the PR-shaped ambition; the watch stays the thread narratorkeep

8.2 The Construct manifest — the honest not-Temporal list

Of ~103 live automations, only rows 4, 5, 7, 9, 10 above are drive-loops in disguise. The rest divide cleanly:

8.3 The Operators — what moves, what stays local, the first slice

The operator plane is entirely files + tmux + one launchd node daemon: the AgentFlow Supervisor (60s sweep — respawn after 2 confirmed-dead readings, cap 5, quiet-stop parks, an answer bridge), detached nudge bash watchers, msg (tmux send-keys + parked inbox files), the blocked ledger (Cloudflare KV canonical + local JSON index), and the Claude CLI's dispatch daemon. Six measured failures (ground truth): the roster-as-prompt spawn loop (~0% of dispatches start; 6.1 GB disk, +2.7 GB/day; 5,054 files in dispatch/rejected/), nudge clamping to 60s while a block is open, msg 8-char-prefix wrong-session delivery, blocked adopt falsely orphaning background sessions' blocks, answered blocks staying "open" with no write-back (humans answer twice), parked instructions draining out of causal order with no expiry.

What must stay local — Temporal cannot type into a terminal. The interactive session, tmux delivery, pane capture, start-operator's spawn dance, nudge's countdown-on-stillness. Durable workflows in this plane drive through activities on the mini's worker — the delivery arm stays local; the loop ownership and state move.

  1. First slice — DecisionBlockWorkflow. One workflow per raised block, id = block id, on propflow-smith. Raise = start (activity publishes the decision page, today's path). The workflow polls the KV answer on a durable timer (subsuming the answer bridge — whose log is currently spinning on Cloudflare read failures) and, on answer, drives the answered→executed leg that today is hoped for: a msg-delivery activity wakes the raiser with the verbatim answer, re-nags on a durable timer until the raiser (or adopter) signals resolved, and escalates if the raiser is provably dead — via the shared three-valued liveness primitive, never collapsing unknown into dead. The human semantics survive exactly: "resolved means the raiser consumed the answer and acted — a claim only the raiser can make" becomes a signal only the raiser's session sends, instead of an unenforced convention. Smallest surface, kills the friction the founder personally feels (answering twice), two measured incidents behind it, and structurally identical to the PR drive. Effort M.
  2. Second — OperatorTaskWorkflow (liveness/respawn/watchdog): per-task workflow subsuming the Supervisor's sweeps, with durable dead-streak counters (today an in-memory map that resets to zero on every Supervisor bounce), durable respawn budget, quiet-stop latches, "operator went dark" escalation. Activities: roster/tmux probes, spawn, loop release. Effort L — after the block slice proves the mini-worker activity seam.
  3. Not Temporal — the dispatch-daemon rot. Roster-as-prompt and the unbounded rejected/ growth are Claude-CLI-layer bugs; wrap nothing — fix at source, plus a prune line in the existing morning hygiene sweep.
  4. Not Temporal — nudge and msg themselves. They are the delivery arm; a durable workflow re-implementing "type into a pane" buys nothing.

8.4 The dead-review watchdog boundary (propflowai #5581)

review-verdict-watchdog.yml is a repo-wide, from-outside sweep that pages #alerts (addressed to Smith) when any active PR waits too long for a verdict that is never coming. The drive subsumes the detection for Smith-driven PRs: AWAITING_VERDICT unchanged past 15 min produces a wake carrying the same playbook — faster than the sweep, threaded in the drive's own conversation, acted on by the same brain without the #alerts hop. The watchdog stays, unchanged: it covers human and non-driven PRs, and its own header argues the principle that keeps it outside the review system it watches. Steady state: for driven PRs the drive fixes dead reviews before the watchdog's window elapses, so its pages concentrate on non-driven PRs. A redundant page on a PR with a live drive is harmless; building dedup between them would re-import exactly the two-driver coordination this plan deletes.

9 · Test plan

FilePins
test_pr_open_detect.py (new)create+URL result → detected; mentioned / gh pr view → not; multi-PR; sibling repo; malformed stream-json → []; paired by tool_use_id.
test_pr_drive_classify.py (new)Pure table: every gate shape → the right class; volatile reason text → same fingerprint; thread-count change → new fingerprint.
test_pr_drive_workflow.py (new)Time-skipping env, mocked activity edges: tick cadence (fast ↔ idle, restored on fp change); fingerprint-change → exactly one wake naming the class; one-wake-per-fp then one escalation then silence; WAIT-state stall promotion with the right hint; terminals (merged wake+finalize, first-tick-merged silent, closed line, stopped); budget; expiry; poke short-circuits the timer; CAN-carried state honors budget and never re-wakes a carried fp; send-refused → completes anyway.
test_claude_runner.pyopened_prs populated; empty chat_jid / error runs → empty; no watch-file write occurs.
test_reply_workflow.pyAuto-start idempotency: child started with the right id/request; [] → zero commands (replay safety as behavior); already-started → no duplicate; fires on webhook/cron turns too.
test_pr_event.pywake() from explicit binding args; compose_text per kind unchanged; main() tests deleted with main().
test_pr_drive_cli.py (new)add binds from env (missing → exit 1); stop signals; adopt converts legacy watch files, idempotent.
registration / canonical-paths / prompts / typesWorkflow + activities registered; drift guard: any claude_runner_activity consumer must wire both start_approval_if_mergeable and start_pr_drive_for_opened; prompt names auto-start, no smith-pr-watch mention survives; additive dataclass defaults.

Gate for every PR: uv run ruff check src tests scripts && uv run mypy && uv run pytest -q.

10 · PR sequence · rollout · drill

  1. PR-1 — the driver, armable but not yet auto-armed. Workflow + activities + wake() extraction + types + prefix + registration + CLI (add/stop/list) + tests. Legacy webhook path untouched; a CLI-armed drive on a throwaway PR (no watch file → no webhook wakes) exercises the driver in genuine isolation — no two-driver window on any real PR.
  2. PR-2 — the cutover. Detection + runner + reply hook + prompts rewrite + delete pr_watch.py + delete pr_event.main() + adopt. Deploy, then run smith-pr-drive adopt — in-flight watch files become drives; the listener's Smith branch goes structurally dead.
  3. PR-3 (propflowai, within a week) — delete the inert listener branch; /review-turns untouched.

Deploy per PR: merge → smith-sync-from-main.sh (worker restart; new class → no patch-gate hazard). No Schedule to register — drives are child- or CLI-started.

Synthetic drill (proves the inversion against the case it exists for):

  1. launchctl stop …review-webhookkill the listener for the entire drill. v1 killed it mid-way to test a backstop; v2 kills it up front to prove the loop never needed it.
  2. Smith opens a throwaway PR. Confirm smith-pr-drive-… Running with zero manual arming (PR-2) / one CLI call (PR-1 bake).
  3. Verdict lands → wake within ~2 min → fix loop → 🟢 → Mergeable: → approval child → ✅ → merged → drive Completed with the wrap-up turn.
  4. temporal workflow query --type status mid-drive shows class/ticks/wakes/fp.
  5. Restart the listener. Confirm nothing about the drive changes.
  6. Negative leg: smith-pr-drive stop on a second drive → "stopped", no further wakes.

Prod acceptance: the next real Smith PR runs cover-to-cover — open → drive Running unprompted → verdict → wake ≤2 min → fix → Mergeable → human ✅ → merged → Completed — with smith-pr-watch absent from the transcript and the listener's Smith branch logging zero invocations.

11 · Open questions for the founder

  1. Tick numbers. 45s active / 5-min idle / 15-min stall / 12 wakes / 36h + CAN. Five concurrent drives at 45s ≈ 24% of the gh REST budget (30s ≈ 36%, 60s ≈ 18%). Bless or adjust.
  2. Non-Smith PRs. Auto-start covers Smith-opened; the CLI covers "drive PR X for me." Recommendation unchanged: never auto-drive a human's PR.
  3. Merged wrap-up turn. Keep the final turn (it posts the done-table today), or reduce to a one-liner? Proposed: keep.
  4. The poke hint. Re-point the listener at it later (~2s reaction), or delete the branch and never look back? Proposed: delete (PR-3); revisit only if 45s demonstrably matters.
  5. Dead-review boundary. Drive subsumes detection for driven PRs; the repo-wide watchdog stays for everything else (§8.4). Agree?
  6. Pattern adoption. Which of §8.1's candidates get green-lit, in what order? Proposed first wave: maintenance_eval (P1), DecisionBlockWorkflow (P1), voice-call-reconciler (P1).
  7. NEW · Parent-close policy under SmithTaskWorkflow (§12.4). When the task container lands, does the PR drive stay ABANDON or become PARENT_CLOSE? Proposed: stay ABANDON. A PR routinely outlives the task that opened it (review rounds, a human ✅ next morning), and a task parent that completes must not kill an in-flight drive.
  8. NEW · Eval-loop review channel (§13.5). Which channel do prompt-change PRs post to for the 👍? A new #eval-review, or an existing one? No default proposed — this is a workspace call.
  9. NEW · Contradicting principles (§13.6). When an extracted principle contradicts one already in the prompt, the workflow must surface the contradiction and stop — never silently overwrite. Confirm that, and say who adjudicates.
  10. NEW · Disposition of the held-back diff (§14). Proposed: drop the held-back core-loop patch; keep its one durable output — the "never background long work from an ephemeral turn" rule — as a prompt rule folded into PR-2. Confirm, or say you want the diff reviewed on its own merits.

12 · NEW — the SmithTaskWorkflow seam (design only)

The founder's question was "should it be started when I kick off a task… perhaps we already have that… I just wanna make sure we are not stepping on the toes of an existing workflow." Both halves have answers, and they point opposite ways: the gap is real, and the precedent for how to fill it already exists and must not be duplicated.

12.1 The gap, measured

Every durable container in the fleet is triggered by a schedule or by a specific shape of event. Nothing is triggered by "a human kicked off a substantive task in a thread." Concretely, on 2026-08-14 alone the same missing primitive produced three incidents:

One primitive would have prevented all three. That is the argument for §12 — not elegance.

12.2 The precedent that already exists — RemediationWorkflow

src/agent_smith/workflows/remediation.py already runs the exact parent→reasoning-child shape a task container needs, and it has been in production since PR-3 · Phase B:

Where it falls short of the general case, precisely: it is Schedule-triggered (07:30 daily), remediation-only, and it completes the instant the child is started — it holds no loop, no phases, and no task state. It proves the mechanism; it is not the container. The correct reading is "extend this pattern", never "build a parallel one" (Constitution §1).

12.3 What SmithTaskWorkflow would be

A durable per-task container, id keyed on (chat_jid, thread_root)the same anchor SmithReplyWorkflow, pr_live_status, thread_phase and SmithTaskWatchWorkflow are all already keyed on, because they all name the same object: one Slack thread, one task. Its job is to hold phases, not to do work:

PhaseWhat it ownsChild it starts
PR drivea PR opened by the task reaching mergeableSmithPrDriveWorkflowthis plan, built now
Long-job supervisiona job that outlives a turn (a mining run, an eval sweep) running to completion with a receipta job workflow — §8.1 row 2 (maintenance_eval) is the first real instance
Narrationthe human-facing "where is this" line on a cadenceSmithTaskWatchWorkflowstarted, never replaced (§12.5)

12.4 The seam — four properties, all already true in v2

This is the section that earns v3. For SmithPrDriveWorkflow to become a child of a future SmithTaskWorkflow with zero rework, four things must hold. Each is already specified in v2, so building the PR drive now costs the task container nothing later:

#PropertyAlready true because…
1The drive is parent-agnostic. It is started as an id-keyed ABANDON child with an explicit task_queue=, and nothing in its body reads or asserts who its parent is.§4 specifies verbatim the start_approval_if_mergeable shape (approval.py:671-691), including the explicit task queue and its stated reason. A task container issues the identical call from a different parent.
2All binding rides the request. chat_jid / thread_ts / anchor_key arrive as PrDriveRequest fields — never a file, never a lookup against the parent.§3.1: "The thread binding lives inside the workflow's durable state" — the property that lets pr_watch.py retire. A task parent passes the same three fields it already holds.
3The outcome is a return value, not a side effect. "merged" | "closed" | "stopped" | "expired" | "wake_budget_exhausted".§3.6. Today nobody awaits it (ABANDON). A task parent can await the same child handle for the same strings and branch on them — no signature change, no new signal.
4The id is keyed on the PR, not the task. smith-pr-drive-<repo__slug>-<pr>.§3.1. This is what lets one task spawn several PR drives, keeps exactly one drive per PR, and makes start_child_workflow idempotent from any caller — the reply hook, the CLI, or a future task container, interchangeably.

What must NOT be done now, and why each would be the rework:

12.5 What it explicitly does NOT subsume

SmithTaskWatchWorkflow stays exactly as it is. Its 2h cap and narrate-only action surface are safety properties, not gaps — its own docstring states it is "disarmed of real-send risk by construction, not by a flag" (task_watch.py:36-43). A task container would start a watch as its narration phase; it would never fold the watch's capabilities into itself, because doing so would delete that constitution. Same conclusion v1 and v2 both reached, restated because a "generalize everything" reading of §12 is exactly how it would get lost.

Effort: L. Priority: after the PR drive is proven in prod. The PR drive is the first phase of the container built standalone; shipping it first is what makes the container a wiring change rather than a research project.

13 · NEW — the eval 👎 self-healing loop (scope only, NOT built)

The founder's ask, in his words: "doing a thumbs down should kick off like a temporal workflow… there's like a self-healing prompt — why did this fail? Maybe I can add some context in the thumbs down and then it should pull out a generalized principle of how to make sure that that's the new standard… make one [PR] with a prompt updated and in the playground confirm that it works and then you post in… a designated review channel where we thumbs up the changes and then you merge them… and enable the next day run the full eval suite. But we have to be mindful — if I have three thumbs-downs they'd have to be done in order so they don't step on each other."

It is a legitimate third instance of this plan's pattern: a durable workflow owns a loop, spawns bounded work only on a real event, and preserves the human gate as a signal. Scoped here; deliberately not built. Everything it does ends in a merged prompt change, and that is not a thing to automate off a design nobody has read.

13.1 Shape

EvalHealWorkflow, one per 👎, id = (graded-artifact-id, grader-uid) so a double-tap is an idempotent no-op. Started by the reaction router — the same approval_routing / slack_action_store seam that already turns a ✅ into a workflow signal (§2 precedent). The optional human context is the reaction's thread reply.

13.2 Phases

  1. Principle extraction — one bounded LLM call in an activity: the 👎'd artifact + the human's context → a generalized principle, not a memorized fact. This is a direct constraint from the founder (2026-08-11): "it's principle based… so the 'common sense' is repeatable and understood. Not just memorized facts." A workflow that emits "don't say X to tenant Y" has failed this phase.
  2. Lane acquisition — §13.3, the load-bearing part.
  3. The change — one branch, one prompt edit carrying the principle, plus the eval case that pins it (Constitution §9: a behavior change ships with its pinning test — and here the test is the artifact that failed).
  4. Confirmation — run the targeted eval suite. The gate is two-sided: the new case must pass and the existing suite must not regress. A failure is reported honestly and the lane is released; it is never retried into a pass (Constitution §4).
  5. Human gate — post to the designated review channel; a 👍 is a signal, verbatim SmithApprovalWorkflow's shape. Merge on 👍 only, through the untouched approval gate.
  6. Release + arm — release the lane, and let the next nightly full-eval run cover the change (no new schedule — maintenance_eval already runs nightly).

13.3 Serialization — the constraint the founder named, and the right mechanism

"if I have three thumbs-downs they'd have to be done in order so they don't step on each other." This is not a nice-to-have: two concurrent prompt-change PRs against the same prompt file produce conflicting diffs and an eval baseline nobody can attribute — you cannot tell which change made the suite pass.

Mechanism: a Temporal mutex workflow keyed on the PROMPT FILEeval-heal-lane-<prompt-slug>. Each EvalHealWorkflow signals the lane to enqueue itself and awaits a granted signal; on completion it signals release. This is the standard Temporal mutex pattern, and Temporal is the right place for it because the queue lives in workflow history: it is durable, ordered, and survives a worker restart, where a file lock or a DDB row would not. Keyed on the file, not globally, so a 👎 on Clara's voice prompt does not queue behind one on the maintenance prompt.

Rejected alternative, stated so it isn't re-proposed: a single lane workflow with id_conflict_policy=FAIL. That serializes but loses the queue — a second 👎 arriving during the first is dropped, which is "stepping on each other" wearing a different hat.

13.4 Reuse (nothing new gets built that already exists)

13.5 / 13.6 The open product calls

Four, all founder questions (§11.8–11.10 plus one embedded here): which review channel; whether the gate is 👍-only or also a text approve; whether confirmation runs the targeted suite or the full one (cost vs confidence); and — the genuinely hard one — what happens when an extracted principle contradicts one already in the prompt. Recommendation on the last: the workflow surfaces the contradiction and stops. Silently overwriting a principle a human previously approved is how a prompt drifts in a direction nobody chose, and it is unrecoverable because the old principle leaves no trace.

14 · NEW — reconciling the held-back core-loop fix

In the Reddit ad-angles thread, a prior Smith instance hit this plan's failure class twice, root-caused it in nearly the same words, merged the checkpoint/resume half of the fix (dotclaude b87aefascripts/propflow-status/reddit-*.mjs + REDDIT-MINING.md), and explicitly held back a core-loop change as too much blast radius to self-merge, flagging it for real review because it touches the reply path that runs on every message.

The reconciliation, so two mechanisms cannot ship: the held-back change and this plan are the same fix at two altitudes.

Instance of the bug classWhere it is fixedWhen
A PR opened in a turn, driven by nothing after the turn endsSmithPrDriveWorkflow — §3Now (this plan)
A long job spawned in a turn, killed at teardown, receipt never postedSmithTaskWorkflow's long-job phase — §12.3Later (design only)
The one live instance inside agent-smith itself: the nohup … maintenance_eval … & block at prompts.py:98-99SmithMaintenanceEvalWorkflow — §8.1 row 2, already ranked P1Next (first adoption after this plan)

Recommended disposition of the held-back diff: drop it as a core-loop patch. Reasons, in order of weight:

  1. Its target is the same class this plan fixes structurally. Patching the reply loop to nurse backgrounded shells would install a second mechanism for turn-outliving work, three sections after this plan argued that a second driver is the disease (§1).
  2. Its one durable output is not code — it is the rule already written down in REDDIT-MINING.md: never background long work from an ephemeral turn; hand it to a durable runner that owns the post. That rule belongs in prompts.py, which PR-2 of this plan already rewrites (§6 row 13). Folding it in there costs one sentence and no blast radius.
  3. The nohup block at prompts.py:98-99 stays until its replacement exists (§8.1 row 2). Removing the only working path before the durable one lands would be destructive-over-additive (Constitution §6) — the rule tells the brain not to invent new background shells; it does not delete the one sanctioned path before there is somewhere for it to go.

If the founder wants the held-back diff reviewed on its own merits instead, that is founder question §11.10 — but it should then be reviewed as a competing mechanism, not as a complement.

PropFlow Docs