Supersedes v1 after the founder's inversion. Repo: PropFlow-Technologies/agent-smith · future home docs/planning/smith-pr-drive-durable.md
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).