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
Nothing was removed and nothing was redesigned. Sections 0–11 are v2 as written and as approved. Three sections were added:
SmithTaskWorkflow seam. The founder's instinct is right and the gap is real: there is no durable container for an arbitrary Slack-kicked-off task. §12 designs that container, names RemediationWorkflow as the narrow precedent it must not duplicate, and states the four properties the PR drive must already have so it can become a child later with zero rework. All four are already in v2's spec — that is the finding, not a new requirement. Design only; not built now.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 mode | v1's treatment | v2's treatment |
|---|---|---|
| Manual arming (the incident) | 3 auto-arm belts + CLI fallback | Opening 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 min | No listener in the path. The only liveness dependency is the Temporal worker — the component the agent already lives or dies by. |
| Double-wake race | note_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.
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.
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 element | Fate | Why deleting it is a win |
|---|---|---|
§2.4 third belt — pr_event signal-with-start on webhook events | Deleted | No webhook events in the path; the belt guarded a seam that no longer exists. |
note_event as dedup / stall-reset between two drivers | Deleted | One driver needs no inter-driver coordination. (A poke signal survives as a future latency-hint seam only.) |
| "Fingerprint movement = someone is acting → suppress wake" | Deleted | Existed 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) | Retired | Both 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 bridge | Re-scoped | compose_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 dark | Replaced | ~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) | Reshaped | The 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.
| Concern | Reused mechanism |
|---|---|
| PR state probe | merge_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 state | the synthetic-inbound path — pr_event.compose_text → build_trigger → dispatcher.dispatch() (start-or-signals one reply workflow per thread). Extracted into pr_event.wake(...). |
| Arming idempotency | WorkflowAlreadyStartedError → no-op, verbatim start_approval_if_mergeable (approval.py:693). |
| Child lifecycle | ABANDON child, id-keyed per PR — verbatim the approval child pattern (approval.py:671-691). |
| Long-run history safety | is_continue_as_new_suggested() + carried-state resume — the reply.py CAN-belt precedent (reply.py:148-179, 1005-1012). |
| Determinism rules | no config import; workflow-local timedeltas; all I/O in activities; imports_passed_through(). |
| Status surface | none new — woken turns ride pr_live_status + the phase table; the drive posts ≤ a handful of one-liners over its life. |
SmithPrDriveWorkflow — the driversmith-pr-drive-<repo__slug>-<pr> via new PR_DRIVE_WF_ID_PREFIX in agent_ident.py (beside APPROVAL_WF_ID_PREFIX). One drive per (repo, PR); re-arm of an open drive is a no-op; after completion the same id may start fresh.propflow-smith (interactive pool, the approval child's sibling; single-home registration per the worker manifest invariant).PrDriveRequest carries repo, pr_number, chat_jid, thread_ts, anchor_key, url, title plus CAN carriers (wakes_so_far, woken_fps, escalated_fps, remaining_seconds). The thread binding lives inside durable workflow state — this is what lets the file registry retire. The runner already injects SMITH_CONV_* (claude_runner.py:393-407).| Name | Effect |
|---|---|
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. |
_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 ✅.
_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.
| Class | Gate evidence (merge_gate.py) | Wake policy |
|---|---|---|
MERGED / CLOSED | pr_state (:140) | terminal (§3.6) |
MERGEABLE | gate.ok (:225) | wake — announce Mergeable: (starts the approval child) |
NEEDS_FIXES | blocking verdict 🟡/🔴/📝 (:228) | wake — the incident case |
NEEDS_THREADS | unresolved_threads > 0 (:171) | wake — resolve review threads |
STALE_VERDICT | verdict_is_stale (:221) | wake — re-trigger the bot on the head |
CI_FAILED | check failed (:151) | wake — fix the red check |
CONFLICTS | CONFLICTING (:143) | wake — rebase |
AWAITING_VERDICT | "no claude-bot review verdict yet" (:201) | wait; unchanged past stall → wake + dead-review hint |
CI_RUNNING | check pending (:156) | wait; unchanged past stall → wake ("CI appears hung") |
GH_ERROR | gh/network failure (:125) | wait; unchanged past stall → wake ("can't read PR state") — self-heals on recovery |
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"
reply.py:166 rule). New class → no patch gates in v1; the docstring carries the standard future-patch-gate note.len(escalated) + 2 one-liners over its life, threaded via slack_send_activity (_SEND_RETRY verbatim from task_watch.py:77; empty thread_ts → thread_anchor_get_activity). SendRefusedError is logged and swallowed — waking turns is the job, posting is best-effort.| Observation | Behavior |
|---|---|
MERGED | one 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 tick | silent finalize, no wake, no post — never announce a completion that predates the request (the task_watch already_done rule). |
CLOSED | one 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 / lifetime | one honest line each; deliberate CLI re-arm starts fresh under the same id. |
activities/pr_drive.py)pr_drive_wake_activity (smith_pr_drive_wake) — the extracted pr_event.wake(...): compose → resolve anchor → build_trigger → record_phase → dispatch(). Binding from arguments, never a file. Timeout 60s, ≤2 attempts — dispatch start-or-signals, so a duplicate wake beats a lost one.pr_drive_finalize_activity (smith_pr_drive_finalize) — best-effort terminal cleanup: phase-row stamp + thread-anchor clear. Replaces v1's pr_watch_remove_activity — there is no watch file to remove.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.claude_runner_activity populates ClaudeReply.opened_prs (additive-defaulted). No watch-file writes — the activity's output is evidence, the workflow acts on it.start_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.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.
| Component | Decision | Reasoning + migration |
|---|---|---|
pr_watch.py (registry + CLI) | Retire (delete), PR-2 | Both 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.py | Re-scope, PR-2 | Keep 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 PR | The 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 hint | Not built; seam reserved | If ~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 tunnel | Out of Smith's drive path | They remain for other consumers. Smith's drive reliability = the Temporal worker's reliability, full stop. |
| # | File | Change |
|---|---|---|
| 1 | pr_open_detect.py (new) | Pure stream-json parser (v1 §2.1 verbatim; reuses phase_table.PR_URL_RE). |
| 2 | activities/claude_runner.py | Populate ClaudeReply.opened_prs post-run (fail-soft). No watch-file writes. |
| 3 | types.py | opened_prs; PrDriveRequest / PrDriveWakeInput / PrDriveFinalizeInput. |
| 4 | workflows/pr_drive.py (new) | The workflow + pure helpers + start_pr_drive_for_opened + id helper. |
| 5 | activities/pr_drive.py (new) | pr_drive_wake_activity, pr_drive_finalize_activity. |
| 6 | pr_event.py | Extract wake(...); binding as args; delete main(). |
| 7 | pr_watch.py | Deleted (PR-2, after adopt). |
| 8 | pr_drive_cli.py (new) | smith-pr-drive add/stop/list/adopt console script. |
| 9 | agent_ident.py | PR_DRIVE_WF_ID_PREFIX. |
| 10 | worker.py | Register the workflow + both activities. |
| 11 | workflows/reply.py | The auto-start hook, with the abstain-precedent comment. |
| 12 | workflows/task_watch.py | Docstring only: the webhook-gap sentence now points at the drive. |
| 13 | prompts.py | Auto-start noted; smith-pr-drive add as fallback; smith-pr-watch ritual removed. |
| 14 | docs/planning/smith-pr-drive-durable.md (new) | This document. |
| 15 | propflowai 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.
| Failure | Detection | Behavior | Recovery |
|---|---|---|---|
| Smith forgets to arm (the incident) | Cannot occur | Arming is the reply hook off runner-observed evidence, not a brain action | — |
| GitHub webhook infra dark | Not in the path | No effect on drives | — |
| Temporal worker down / deploy restart | Task-timeout + replay on return | Timers/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-limit | GH_ERROR class | Waits through the stall window (transients self-heal invisibly); persistent outage → one honest wake, one escalation, then silence on that fp | fp changes when gh recovers |
| Dead review | AWAITING_VERDICT unchanged past stall | Wake carries the dead-review playbook hint; the brain runs the re-trigger | Verdict lands → new fp. Boundary with the repo watchdog: §8.4 |
| CI hangs | CI_RUNNING unchanged past stall | One wake ("CI appears hung") | fp moves when CI settles |
| Woken turn crashes mid-fix | fp doesn't move | One escalation line ("still stuck — needs a human") | Any push moves the fp and re-opens the cycle |
| Duplicate arming (hook + CLI) | WorkflowAlreadyStartedError | No-op — one drive per PR by id | — |
| Armed on an already-merged PR | First tick → MERGED | Silent finalize | — |
| Closed without merge | CLOSED | One honest line; the 3-day watch-file leak class is deleted | — |
| Wake budget / lifetime | counter / deadline | One honest line each | Deliberate re-arm starts fresh |
| History growth | is_continue_as_new_suggested() + heartbeat check | CAN carrying budget + dedup state | — |
| Slack send refused | non-retryable | Logged; drive continues | — |
| Wake latency | — | ≤45s active / ≤5m idle-fp — the accepted trade-off | poke seam if it ever matters |
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).
| # | Candidate | Current mechanism | Failure class (measured) | Adoption shape | Effort | Rank |
|---|---|---|---|---|---|---|
| 1 | Smith PR drive | webhook chain + manual arm | 5h silent stall on #5749 (2026-08-14) | This plan | M | P0 |
| 2 | maintenance_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-447 | SmithMaintenanceEvalWorkflow: 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 coupling | S/M | P1 |
| 3 | Blocked-decision ledger (operators) | CF-KV answers + local ledger + an answer bridge that writes answer but never state | 2026-08-10: 40 answers landed, 28 read unresolved an hour later; humans answer twice | DecisionBlockWorkflow per block — §8.3, the first slice | M | P1 |
| 4 | voice-call-reconciler (propflowai) | Vercel cron * * * * * — 1,440 runs/day polling Twilio for stuck attempted_pending calls | Per-call wait as a per-minute fleet sweep; no give-up, ever | The voice workflow races webhookSignal vs sleep(timeout), polls Twilio once on timeout. Deletes the cron — best cost/benefit in the fleet | S | P1 |
| 5 | alert-remediation episodes | Daily one-shot scan → fire-and-forget deciders; episode/noise memory in ~/.claude/smith-state/*.json on one Mac's disk; human ✅ is polled, not awaited | Lose smith-state/ → forgets every episode and every human "this is noise" ruling, re-drives the ledger | One 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 here | M | P2 |
| 6 | Morpheus 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 retried | Child dies → ack stands, proposal never lands, no alert, no retry; evidence only in a log file | MorpheusAuthorWorkflow: heartbeating activity; dedup by workflow id; completion posts the receipt or an honest failure | M | P2 |
| 7 | renewal-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 id | Temporal retry policies + per-saga state give all of it for free. Works today — migrate on the next renewal-architecture touch | M | P3 |
| 8 | Transcript-pipeline receipt | The 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 ack | Daemon 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 thread | S | P3 |
| 9 | escalation-matter-nag | Daily cron over EscalationMatter rows | Granularity: a 15:05 deadline waits ~24h; a missed run extends every deadline undetected | Per-matter durable timer + reply signal = the ADR-0104 cadence it is already slated to become. Defer: ride the production build | — | defer |
| 10 | yale-readiness | Daily digest + manual-retirement TODO | Nobody is told when the signal flips; the table posts forever | wait_condition(green) → announce → hold 7d stable → complete | S | P3 |
| 11 | nightly_program bounded-wait | Queue-drain ledger + bounded wait for fan-out workers in a plain subprocess; inert behind rollout gates | Same class as #2 at overnight duration | Wait + ledger into workflow state — before it is armed, not after | M | P3 |
| 12 | SmithTaskWatchWorkflow | Already durable | — | Keep; 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 narrator | — | keep |
Of ~103 live automations, only rows 4, 5, 7, 9, 10 above are drive-loops in disguise. The rest divide cleanly:
conversation-capture-drift*, conformance, outbound-language, cotenant-*, touch-ledger, detector.*), every AppFolio EventBridge mirror, the metric-snapshot Lambdas, spend-snapshot, listings-sync, the outreach-reconcile backstops (which heal into durable Temporal cadences). Fresh independent measurements with externalized dedup — Temporal would add nothing but cost.renewal-workflow-health / leasing-workflow-health poll Temporal from Vercel — a Temporal workflow cannot report Temporal's own absence. Keep. Same principle keeps daemon-liveness on Temporal (watching launchd) and the review watchdog on GitHub Actions (§8.4).maxStaleness on the manifest entry, checked by the fleet digest against latest.startedAt — closes the gap for ~40 jobs at once, preserving the correct stateless shape.~/.claude/smith-state/*.json on one un-replicated disk (episode ledgers, noise rulings, at-most-once markers, poll cursors, a zero-byte lock file standing in for a mutex), while the TS side keeps equivalents in DDB. Rows 5–6 move the highest-stakes of that state into workflow history.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.
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.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.rejected/ growth are Claude-CLI-layer bugs; wrap nothing — fix at source, plus a prune line in the existing morning hygiene sweep.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.
| File | Pins |
|---|---|
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.py | opened_prs populated; empty chat_jid / error runs → empty; no watch-file write occurs. |
test_reply_workflow.py | Auto-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.py | wake() 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 / types | Workflow + 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.
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.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./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):
launchctl stop …review-webhook — kill 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.smith-pr-drive-… Running with zero manual arming (PR-2) / one CLI call (PR-1 bake).Mergeable: → approval child → ✅ → merged → drive Completed with the wrap-up turn.temporal workflow query --type status mid-drive shows class/ticks/wakes/fp.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.
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.maintenance_eval (P1), DecisionBlockWorkflow (P1), voice-call-reconciler (P1).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.#eval-review, or an existing one? No default proposed — this is a workspace call.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.
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:
~/.claude/scripts/propflow-status/REDDIT-MINING.md: "A node … & spawned in a turn is KILLED at turn teardown, and its completion notification fires into a dead session that cannot post an update."One primitive would have prevented all three. That is the argument for §12 — not elegance.
RemediationWorkflowsrc/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:
propflow-batch-queue selects targets, then starts SmithReplyWorkflow as a fire-and-forget ABANDON child on the interactive propflow-smith queue "to drive a PR to MERGEABLE" (remediation.py:10-15, 89-99)._REPLY_WF = "SmithReplyWorkflow", remediation.py:57) precisely so the parent module never imports the heavy worker-registered class — avoiding the worker ⇄ workflow import cycle. Any future task container must copy this, not rediscover it.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).
SmithTaskWorkflow would beA 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:
| Phase | What it owns | Child it starts |
|---|---|---|
| PR drive | a PR opened by the task reaching mergeable | SmithPrDriveWorkflow — this plan, built now |
| Long-job supervision | a job that outlives a turn (a mining run, an eval sweep) running to completion with a receipt | a job workflow — §8.1 row 2 (maintenance_eval) is the first real instance |
| Narration | the human-facing "where is this" line on a cadence | SmithTaskWatchWorkflow — started, never replaced (§12.5) |
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:
| # | Property | Already true because… |
|---|---|---|
| 1 | The 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. |
| 2 | All 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. |
| 3 | The 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. |
| 4 | The 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:
ABANDON. A PR outlives its task routinely (a human ✅ lands next morning). A task parent that completes must not terminate an in-flight drive. → founder question §11.7.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.
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.
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.
SmithApprovalWorkflow's shape. Merge on 👍 only, through the untouched approval gate.maintenance_eval already runs nightly)."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 FILE — eval-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.
approval_routing + slack_action_store.SmithApprovalWorkflow, untouched.eval_scorer, maintenance_eval, tasks_eval.SmithPrDriveWorkflow — this plan. The eval loop is a consumer of the thing being built now, which is a second reason to build it now and the loop later.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.
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 b87aefa — scripts/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 class | Where it is fixed | When |
|---|---|---|
| A PR opened in a turn, driven by nothing after the turn ends | SmithPrDriveWorkflow — §3 | Now (this plan) |
| A long job spawned in a turn, killed at teardown, receipt never posted | SmithTaskWorkflow's long-job phase — §12.3 | Later (design only) |
The one live instance inside agent-smith itself: the nohup … maintenance_eval … & block at prompts.py:98-99 | SmithMaintenanceEvalWorkflow — §8.1 row 2, already ranked P1 | Next (first adoption after this plan) |
Recommended disposition of the held-back diff: drop it as a core-loop patch. Reasons, in order of weight:
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.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.