Agent Smith · Implementation Plan

Smith PR-Drive, made durable — auto-armed watches + a Temporal backstop that can drive

Status: proposed · 2026-08-14 · Repo: PropFlow-Technologies/agent-smith · Future home: docs/planning/smith-pr-drive-durable.md

0 · The incident, in one paragraph

2026-08-14: Smith opened propflowai #5749, said "I'll drive it to mergeable and let you know", and ended the turn without running smith-pr-watch add 5749 — the one manual CLI step that binds the GitHub webhook to the thread. The bot's 🟡 CHANGES SUGGESTED verdict landed into a dead session; nothing woke Smith; five hours of silence until the human asked "You never gave an update, why." One manual smith-pr-watch add later, the rest of the drive worked flawlessly (webhook → wake → fix → re-review → 🟢 → merge). The machinery works; the arming is manual, the durable primitive can't act, and there is no backstop when the webhook path is dark.

GapFixWhere
A — arming is manualAuto-arm the existing pr_watch registry from the runner activity, off observed gh pr create evidence in the turn's own stream-json logactivities/claude_runner.py + new pure parser
B — durable primitive can't driveNew SmithPrDriveWorkflow — a per-PR durable reconciler modeled on approval.py, whose only action is spawning a reasoning turn through the same synthetic-inbound path pr_event.py usesworkflows/pr_drive.py + one wake activity
C — no backstopThe same workflow's timer-based reconcile tick reads merge_gate_activity (the existing PR-state instrument) and wakes a turn when an actionable state sits un-acted — webhook or no webhooksame workflow

Invariant preserved: the merge stays human-gated through SmithApprovalWorkflow's ✅ flow, untouched. The drive gets a PR to mergeable; it never merges.

1 · Design decision — extend SmithTaskWatchWorkflow vs. new SmithPrDriveWorkflow

Decision: (b) — a new per-PR SmithPrDriveWorkflow, modeled on approval.py. Do not extend SmithTaskWatchWorkflow.

This is not a §1 (DRY) violation, because the two candidate parents are different precedents for different jobs, and §2 says match the named precedent:

SmithTaskWatchWorkflow stays exactly what it is — the thread narrator. Its docstring's gap sentence ("a drive with no webhook traffic never wakes anything") gets updated to point at the new workflow.

What stays DRY — no second store, poller, or registry

ConcernReused mechanism (not forked)
PR state probemerge_gate_activity — already registered, already returns ok / recoverable / reason / head_sha / pr_state / unresolved_threads / verdict_is_stale: precisely a reconciler's input. No new gh poller.
Acting on statethe synthetic-inbound path — pr_event.py's compose_text → build_trigger → dispatcher.dispatch(). The core is extracted into one callable used by both the CLI and the new wake activity. One wake path.
Webhook routingpr_watch.py JSON files stay the listener's cheap "is Smith driving this PR?" filter (listener.js:384). Kept, not retired — §6.
Status surfacenone new. The drive posts almost nothing itself; the turns it wakes ride the existing pr_live_status heartbeat collapse. Its own posts (escalation, expiry) are one-liners threaded in the PR thread.
Arming idempotencyWorkflowAlreadyStartedError → no-op, verbatim the start_approval_if_mergeable / start_task_watch_if_requested pattern.

Why (a)'s webhook-down story is also worse: the task-watch probe reads the thread_phase evidence store — which is written by pr_event.py (pr_event.py:119-145), i.e. by the webhook path. With the listener down, the store freezes and the probe sees nothing wrong forever. A backstop must read GitHub truth (merge_gate_activity shells gh), not a mirror the dead path was supposed to refresh. That alone disqualifies a PROBE_PR_STATE inside task-watch unless the probe also grew a gh activity — at which point it is approval.py's poll loop wearing task-watch's clothes, with the wrong key, the wrong caps and the wrong safety contract.

Double-wake risk: both paths converge on dispatcher.dispatch(), which start-or-signals one reply workflow per (chat_jid, thread_root) (dispatcher.py:180). A race produces two turns in one conversation, never two competing conversations — the same behavior two clustered CI webhooks already produce today. The drive additionally suppresses its wake whenever the gate fingerprint moved since the last tick, and webhook events signal note_event into the drive, resetting its stall clock. Residual worst case: one redundant heartbeat turn.

Worker deploys / restarts: the drive is a Temporal workflow on propflow-smith — a smith-sync-from-main.sh restart replays it exactly like SmithApprovalWorkflow today. New workflow class → v1 needs no workflow.patched() gates; the docstring carries the standard "future command-sequence changes must be patch-gated" rule.

2 · Auto-arm — where arming happens so no manual step remains

Decision: layer (i) — the runner activity — as the primary, evidence-based arm; the reply-workflow end-of-turn hook arms the durable drive off the same evidence; pr_event.py signal-with-starts the drive as the third belt. The brain's smith-pr-watch add prompt instruction stays as a fallback for create paths the detector can't see.

2.1 Detection — gh pr create observed in the turn's own log

New src/agent_smith/pr_open_detect.py (pure, no I/O — the watch_intent.py posture):

PR_URL_RE  # reuse phase_table.PR_URL_RE — one definition (§1)
def opened_prs(stream_json_lines: Iterable[str]) -> list[tuple[str, int]]

Parses the claude stream-json activity log for a Bash tool_use whose command contains gh pr create, pairs it (by tool_use_id) with its tool_result containing a GitHub PR URL (gh pr create prints the URL on success), and returns deduped (repo, pr_number) pairs. Keying on the create-command + its result is what makes this fire on PRs opened this turn and not on PRs merely mentioned — the false-positive the prompt-text candidates could not exclude. Precedent: audit_session_activity already post-scans this same log for git-commit evidence.

2.2 Primary arm — inside claude_runner_activity (Gap A, zero workflow-history impact)

activities/claude_runner.py already reads the log post-run (_extract_error_detail) and already holds the conversation binding — it injects SMITH_CONV_CHAT_JID / SMITH_CONV_THREAD_TS / SMITH_CONV_ANCHOR_KEY into the child env (lines 393–407). After the subprocess completes, on the non-error path:

opened = pr_open_detect.opened_prs(log_lines)          # pure
for repo, pr in opened:                                 # fail-soft, try/except OSError
    pr_watch.add_watch(repo, pr, input.chat_jid, input.thread_ts,
                       anchor_key=input.anchor_key)
reply.opened_prs = [f"{repo}#{pr}" for repo, pr in opened]

2.3 Durable-drive arm — end-of-turn hook in reply.py (twin of the existing two hooks)

New start_pr_drive_for_opened(chat_jid, opened_prs, *, thread_ts, anchor_key) in workflows/pr_drive.py, called in SmithReplyWorkflow._run_turn immediately after the task-watch hook (reply.py:1358-1373). Starts one drive per opened PR as an ABANDON child, id smith-pr-drive-<repo__slug>-<pr> (mirrors the approval keying, approval.py:669); WorkflowAlreadyStartedError → leave the running drive alone.

Replay safety without a patch gate — by the abstain precedent (reply.py:1263-1276): the hook's sole trigger is ClaudeReply.opened_prs, an additive-defaulted activity-result field. Replay never re-executes activities; every pre-deploy history's recorded ClaudeReply deserializes with opened_prs=[], so the hook is a zero-command no-op on every old history. (Contrast _TASK_WATCH_PATCH_ID, which did need a gate because its trigger — a human sentence — could pre-exist in recorded inbound text.) The code comment must state this reasoning explicitly.

2.4 Third belt — pr_event.py signal-with-start

After the existing dispatch (pr_event.py:222-232), best-effort:

await client.start_workflow(
    SmithPrDriveWorkflow.run, drive_request_from_watch(args.repo, args.pr, watch),
    id=pr_drive_workflow_id(args.repo, args.pr), task_queue=TASK_QUEUE,
    start_signal="note_event", start_signal_args=[args.kind],
)

Effect: any PR with a watch file — however armed (auto-arm, brain CLI, a human by hand) — gets its durable drive on the first webhook event, and every event thereafter resets the drive's stall clock via note_event.

3 · SmithPrDriveWorkflow — the durable reconciler

New src/agent_smith/workflows/pr_drive.py. Determinism rules as everywhere: no config import, workflow-local timedelta constants, all I/O in activities, workflow.unsafe.imports_passed_through() for activity/type imports.

3.1 Types (types.py, additive)

@dataclass
class PrDriveRequest:
    repo: str
    pr_number: int
    chat_jid: str          # Slack channel of the driving conversation
    thread_ts: str = ""    # PR thread root when known ("" → anchor resolution at wake time)
    anchor_key: str = ""   # synthetic-drive anchor identity (decider case)
    url: str = ""
    title: str = ""

@dataclass
class PrDriveWakeInput:
    repo: str
    pr_number: int
    kind: str              # "reconcile" — joins pr_event.KINDS' vocabulary
    summary: str           # the gate's reason, verbatim (incl. dead-review playbook hint)
    chat_jid: str = ""     # fallback binding when the watch file is missing/corrupt
    thread_ts: str = ""

@dataclass
class PrWatchRemoveInput:
    repo: str
    pr_number: int

3.2 Signals / query

NamePayloadEffect
note_event (signal)kind: str (review/check/merged)records webhook liveness: resets the stall clock; merged sets the terminal flag so the drive completes without waiting for the next tick
stop (signal)operator off-switch; drive completes "stopped"
status (query)f"{phase} ticks={n} wakes={m} fp={fingerprint}" — mirrors task_watch.progress

3.3 Constants (workflow-local; proposed defaults — founder question 9.1)

_RECONCILE_INTERVAL = timedelta(minutes=10)   # tick cadence; ~2 gh reads/tick, trivial vs. 5000/hr
_STALL_AFTER       = timedelta(minutes=15)    # unchanged actionable fingerprint must persist this long
_ONE_WAKE_PER_FP   = True                     # bounded by construction (§3.4)
_MAX_WAKES         = 12                       # absolute 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 dead-review playbook), then end the turn."

3.4 The loop

deadline = now + _DRIVE_LIFETIME
last_fp = None; fp_since = now; woken_fps = []; escalated_fps = []; wakes = 0
while workflow.now() < deadline and not (self._merged or self._stopped):
    await wait_condition(lambda: self._merged or self._stopped or self._event_seen,
                         timeout=_RECONCILE_INTERVAL)     # durable timer, early-wake on signal
    if self._event_seen: self._event_seen = False; fp_since = now   # webhook alive → stall resets
    if self._merged or self._stopped: break
    gate = merge_gate_activity(repo, pr)                  # the ONE probe (reused)
    if gate.pr_state in ("MERGED", "CLOSED"): break       # terminal from GitHub truth
    fp = f"{gate.head_sha}|{gate.ok}|{gate.recoverable}|{gate.reason}"
    if fp != last_fp: last_fp, fp_since = fp, now; continue   # movement = someone is acting
    if not _actionable(gate): continue                    # CI running / bot mid-review → wait
    if now - fp_since < _STALL_AFTER: continue
    if fp in woken_fps:
        if fp not in escalated_fps and wakes > 0:
            post "⏱ backstop: woke a turn and #N is still stuck on <reason> — needs a human"
            escalated_fps.append(fp)
        continue                                          # one wake + one escalation per fp, then silent
    if wakes >= _MAX_WAKES: post budget line; return "wake_budget_exhausted"
    await pr_drive_wake_activity(repo, pr, "reconcile", summary_for(gate))
    wakes += 1; woken_fps.append(fp)
# terminal
pr_watch_remove_activity(repo, pr)   # best-effort cleanup of the webhook router file
return "merged" | "closed" | "stopped" | "expired"

_actionable(gate) (pure module-level function, unit-tested):

Posting discipline: the drive posts at most 2 + len(escalated_fps) one-liners over its whole life, threaded under thread_ts via the existing slack_send_activity (retry policy verbatim from task_watch.py:77). When thread_ts is empty it first tries thread_anchor_get_activity with anchor_key. Everything louder comes from the turns it wakes, which ride the existing pr_live_status collapse — no new status surface.

3.5 Activities (new src/agent_smith/activities/pr_drive.py)

ActivityBody
pr_drive_wake_activity
name smith_pr_drive_wake
Calls the extracted pr_event.wake(repo, pr, kind, summary, fallback_chat_jid, fallback_thread_ts) — load watch → resolve anchor → build_triggerrecord_phasebuild_client()dispatch(). When the watch file is missing/corrupt, builds the trigger from the fallback binding the drive carries. Timeout 60s, RetryPolicy(maximum_attempts=2) — a duplicate turn beats a lost one (the at-least-once posture Slack sends already take).
pr_watch_remove_activity
name smith_pr_watch_remove
pr_watch.remove_watch(repo, pr) — best-effort, mirrors pr_event's merged path.

Refactor inside pr_event.py (§1 — one wake path): extract the body of main() between watch-load and dispatch into async def wake(...); main() becomes parsing + wake() + the merged-cleanup block. Byte-equivalent CLI behavior, pinned by the existing tests/test_pr_event.py.

4 · File-level change list

#FileChange
1src/agent_smith/pr_open_detect.py newPure stream-json parser: opened_prs(lines) → list[(repo, pr)]. Reuses phase_table.PR_URL_RE.
2src/agent_smith/activities/claude_runner.pyPost-run: detect opened PRs from the activity log; pr_watch.add_watch(...) per PR (fail-soft, skip on empty chat_jid); populate ClaudeReply.opened_prs.
3src/agent_smith/types.pyClaudeReply.opened_prs: list[str] (additive default); new PrDriveRequest, PrDriveWakeInput, PrWatchRemoveInput.
4src/agent_smith/workflows/pr_drive.py newSmithPrDriveWorkflow (signals note_event/stop, query status, loop §3.4); pure _actionable + _fingerprint; hook start_pr_drive_for_opened(...) + pr_drive_workflow_id(repo, pr); docstring carries determinism + future-patch-gate + merge-stays-human notes.
5src/agent_smith/activities/pr_drive.py newpr_drive_wake_activity, pr_watch_remove_activity.
6src/agent_smith/pr_event.pyExtract wake(...) from main(); after dispatch, best-effort signal-with-start of the drive (start_signal="note_event").
7src/agent_smith/agent_ident.pyPR_DRIVE_WF_ID_PREFIX (per-agent slug, mirrors APPROVAL_WF_ID_PREFIX).
8src/agent_smith/worker.pyRegister SmithPrDriveWorkflow in SMITH_WORKFLOWS (~197-224) + the two activities (~251-267).
9src/agent_smith/workflows/reply.pyCall start_pr_drive_for_opened in _run_turn after the task-watch hook (~1373), guarded on reply.opened_prs; comment states the abstain-precedent no-patch reasoning.
10src/agent_smith/workflows/task_watch.pyDocstring only: the gap sentence now points at SmithPrDriveWorkflow; watch = narrator, drive = actor.
11src/agent_smith/prompts.py"# Opening a PR" → note the watch auto-arms off the observed gh pr create and a durable backstop re-wakes the drive if webhooks go quiet; keep smith-pr-watch add <N> as the explicit fallback for scripted create paths.
12docs/planning/smith-pr-drive-durable.md newThis document.
13TestsSee §7.

No changes to: listener.js (webhook path untouched), pr_watch.py, approval.py, pr_live_status.py, thread_phase.py, watch_intent.py, dispatcher.py.

5 · Failure-mode table

FailureDetectionBehaviorRecovery
Webhook listener downTick sees the gate fingerprint stuck on an actionable state past _STALL_AFTER, no note_eventOne wake turn fires ≤ ~25 min after the state became actionable; the turn reads GitHub truth and actsDrive keeps reconciling until merge/close; listener restart resumes the fast path
Single webhook delivery lostSame — the drive never depended on the eventSameNothing to do
Worker down / deploy-restart mid-driveTemporal task-timeout + replay on worker returnTimers and signals are durable; the drive replays and resumes exactly (v1: new class, no patch-gate hazard)None needed; future command-sequence changes must be patch-gated (docstring rule)
Duplicate wakes (webhook + backstop race)Both converge on dispatcher.dispatch() → one reply workflow per thread; drive suppresses on fingerprint movement and note_eventWorst case: one redundant heartbeat turn (edits the live line, no thread burial)Self-limiting; one wake per fingerprint bounds repeats
Armed on an already-merged PRFirst tick's gate → pr_state == "MERGED"Completes "merged" silently (never announce a completion predating the request — the task-watch already_done posture); removes the watch file
PR closed without mergeTick sees pr_state == "CLOSED"Completes "closed", removes the watch file (fixes today's 3-day _MAX_WATCH_AGE leak window)
Bot review never posts (dead review)Fingerprint recoverable / awaiting bot verdict unchanged past the stall windowWake turn's summary carries the playbook (gh workflow run claude-code-review.yml -f pr_number=<N>); the brain, not the workflow, runs itStill stuck on the same fingerprint after the wake → one escalation line, then silence on that fp
Claude turn crashes mid-fixReply workflow posts its inline error note (existing); fingerprint doesn't moveNext actionable tick: fp already woken → escalation line ("still stuck — needs a human")Human intervenes; any new push moves the fingerprint and re-opens the cycle
Watch file missing/corrupt at wake timepr_watch.load_watch → None inside the wake activityWake activity falls back to the binding the drive carries — the wake still lands in the right threadBrain can re-run smith-pr-watch add; the drive works either way
Wake budget exhaustion (_MAX_WAKES)Counter in the loopOne honest budget line in-thread; returns "wake_budget_exhausted"Human takes over; re-arm deliberately if wanted
Drive lifetime expiry (36h, PR still open)Deadline checkOne honest expiry line with the PR URL; watch file left in place (webhook path still live)Human decides; re-arm restarts a fresh drive under the same id
Slack send refused (escalation/expiry line)SendRefusedError non-retryableLogged, drive continues — its job is waking turns; posting is best-effort

6 · pr_watch.py — kept as the webhook router (not retired)

  1. The listener needs a zero-dependency filter. listener.js:384 checks for the watch file before shelling Python at all — non-Smith PRs cost a stat(), nothing more. Replacing that with a Temporal visibility query from Node adds a client, credentials, and a network dependency to a fail-soft-local hot path.
  2. It is the wake activity's thread-binding source (with the drive's own request as fallback), and the anchor-key carrier for thread-less decider drives.
  3. Retirement buys nothing: ~40 lines of tested registry; the drive holds lifecycle, the file holds routing.

Two free improvements: the drive removes the file on close-without-merge (today only lazy-expired after 3 days), and a corrupt/missing file no longer kills a wake (activity fallback binding).

7 · Test plan

Patterns: time-skipping WorkflowEnvironment + name-matched mock activities (tests/test_task_watch_workflow.py), pure-function tables (tests/test_watch_intent.py), CLI/env round-trips (tests/test_pr_watch.py), drift guards (tests/test_canonical_paths.py, tests/test_worker_registration.py). Gate for every PR: uv run ruff check src tests scripts && uv run mypy && uv run pytest -q.

FilePins
tests/test_pr_open_detect.py newgh pr create tool_use + URL-bearing tool_result → detected; PR URL in plain text or gh pr view output → not detected (the mentioned-vs-opened line); multi-PR turn → both; sibling-repo create → right repo; malformed/truncated stream-json → [], never raises; result paired by tool_use_id, not adjacency.
tests/test_claude_runner.py (extend)Auto-arm writes the watch file with the input's binding (tmp PR_WATCH_DIR); opened_prs populated; empty chat_jid → no file, no crash; unwritable dir → fail-soft (reply still returned); is_error run → no arm.
tests/test_pr_drive_workflow.py newTime-skipping env, mocked smith_merge_gate/smith_pr_drive_wake/smith_pr_watch_remove/smith_slack_send: note_event("merged") → terminal + watch-remove + zero wakes; tick sees MERGED → same; stalled actionable fp → exactly one wake carrying the gate's reason; fp changes between ticks → zero wakes; recoverable inside stall → zero wakes; recoverable-unchanged past stall → wake with dead-review hint; same fp post-wake → one escalation line then silence; _MAX_WAKES → budget line + terminal; lifetime expiry → expiry line; note_event resets the stall clock; stop"stopped"; send-refused on escalation → drive still completes.
tests/test_reply_workflow.py (extend)opened_prs=["…#7"] → drive child started with the right id/request; opened_prs=[] → zero drive commands (replay-safety asserted as behavior); already-started child → no crash, no duplicate; hook fires on webhook-sender turns too.
tests/test_pr_event.py (extend)main() still dispatches + removes watch on merged (extraction behavior-preserving); signal-with-start issued with the event kind; Temporal signal failure → wake still dispatched (fail-soft ordering).
tests/test_worker_registration.py (extend)SmithPrDriveWorkflow + both new activities registered on the home worker.
tests/test_canonical_paths.py (extend)Drift guard: any workflow running claude_runner_activity must call start_approval_if_mergeable and start_pr_drive_for_opened — the incident class this plan kills.
tests/test_prompts.py (extend)"# Opening a PR" mentions auto-arm + backstop; still names smith-pr-watch add as fallback; still forbids polling loops.
tests/test_types.py (extend)New dataclasses deserialize with defaults; ClaudeReply without opened_prs deserializes to [].

8 · Rollout

PR sequence — each independently shippable; merge auto-deploys via smith-sync-from-main.sh

PRContentsWhy this order
PR-1 ships firstAuto-arm the existing shim: pr_open_detect.py + claude_runner.py arm + ClaudeReply.opened_prs + testsNear-zero risk (activity-internal, fail-soft, no workflow-history impact, no listener change) and it kills the incident class — a forgotten arm can no longer dead-end the webhook path — the day it deploys.
PR-2The durable drive: workflows/pr_drive.py + activities/pr_drive.py + pr_event.wake() extraction + types + agent_ident prefix + worker.py registration + the reply.py hook + testsThe backstop; works standalone (gh-state-driven, no dependency on PR-3).
PR-3Wiring polish + docs: pr_event signal-with-start + prompts.py + task_watch.py docstring + this doc committed + testsMakes the drive quieter/faster and closes the CLI-armed-PR arm path.

Deploy per PR: merge to main~/.claude/scripts/smith-sync-from-main.sh (worker restart). No listener restart at any step (listener.js untouched). No Temporal schedule — drives are child-started, not scheduled.

Prod verification

9 · Open questions for the founder

  1. Backstop numbers. Proposed: 10-min tick, 15-min stall window, one wake + one escalation per stuck fingerprint, 12 wakes max, 36h drive lifetime. Faster (5-min tick) is cheap on gh quota but wakes more turns; slower doubles the worst-case silent window (~25 min as proposed).
  2. Scope: non-Smith PRs. Auto-arm covers PRs opened in a Smith turn; the brain's CLI covers "drive PR X for me". Should the listener also arm a drive for any Smith-authored PR with no watch? Proposed: no — an unasked-for driver on someone's PR is exactly the unbidden noise the watch-intent parser was built to avoid.
  3. Noise tolerance. The escalation line ("backstop woke a turn and #N is still stuck — needs a human") and the expiry line are the only new posts. Acceptable per stuck state, or route escalations to #alerts instead of the PR thread?
  4. Dead-review auto-retrigger. Proposed: the woken brain runs gh workflow run claude-code-review.yml -f pr_number=<N> itself (bounded by one-wake-per-fingerprint). Alternative: post the playbook to #alerts for a human. Proposed default: the brain does it — it's the existing playbook and already something Smith does when asked.
  5. Drive lifetime vs. approval window. 36h chosen to outlive the approval's 24h human window. If a PR routinely waits days for a human ✅, should the drive re-arm itself (continue-as-new with a fresh budget) instead of expiring? Proposed: expire honestly — a multi-day PR is a human's queue, not a robot's.
PropFlow Docs