0024 — One thread, one pointer: the heartbeat's Slack table is retired and the ticker feeds the card
- Status: Accepted. The mechanism is in
gera-propflow/dotclaude#45
(
scripts/propflow-status/operator-heartbeat.py). Written at decision time per ADR-0004, not on merge. - Date: 2026-09-11 ⚠️ VOCABULARY: the role this ADR calls the
Operator is the Driver since 2026-09-10 — see
ADR-0021.
co.propflow.operator-heartbeat,operatorSidandop-<slug>are live invocations and deliberately lag. (Appended toDateon purpose: this adds NO line.) - Deciders: Settled by the
kickoff-table-to-agentflowDriver against a Fable ruling (~/.claude/jobs/fable/f0c171952.json, RESOLVED, held under reversal, confidence high / cost-if-wrong high). The question asked was whether "which surface the thread carries" is a human's call. Fable's verdict: it is not — "'no Slack phase table — that render path deleted' is a Gera-anchored criterion superseding the 2026-08-06 table ask thatoperator-heartbeat.py's docstring cites… the heartbeat is exactly ADR-0004's un-inventoried survivor, and that doctrine says the finisher removes/fences it and writes the ADR — not raises a re-askblockedwould refuse as already-answered." - Scope. This amends ADR-0007 — see Amendment below. It does not restate ADR-0007's transport rule (Smith derives, the board displays, one resolver in Python), which is unchanged and is the reason this amendment is possible at all.
The failure
agent-smith #371 (merged 2026-09-04) cut the thread's phase surface over: the
Slack message became one line and one /agents link
(phase_table.render_pointer), and the eleven rows moved to the
/agents/<sessionId> close-up, fed by the phases.json snapshot ADR-0007
specifies. The old render_live survives in agent-smith only behind
not self._pointer_only, for pre-cutover Temporal replay.
The cutover missed a caller in another repo.
~/.claude/scripts/propflow-status/operator-heartbeat.py, run every 120s by the
co.propflow.operator-heartbeat LaunchAgent, was the only remaining live caller
of phase_table.render(...) — the full table. It posted and chat.update-edited
the same eleven rows into the same Slack thread, from a message of its own.
Measured on ~/.claude/smith-state/outbound-ledger.db, 2026-09-07 → 2026-09-11:
-- ⚠️ NOTE THE BACKTICKS. The same pattern without them returns 0 and reads as
-- reassurance; that wrong-instrument zero was the first answer this
-- investigation got, and it was wrong.
select source, count(*), min(posted_at), max(posted_at) from posts
where posted_at >= '2026-09-07' and text like '%| `plan` |%' group by 1;
-- operator-heartbeat | 18 | 2026-09-07T04:37:54+00:00 | 2026-09-11T20:08:00+00:00
-- ⚠️ AS OF 20:27Z 2026-09-11 — the last tick before the cutover. Re-run it TODAY
-- and it reads 22, max 2026-09-11T20:33:05Z. The four extra rows are the
-- cutover's own churn, not a broken claim: the reclaim deleted both frozen
-- tables at 20:28Z, a still-OLD live copy of the script re-posted them at
-- 20:30:38/20:30:42 and edited them at 20:33 (the retired code renders no
-- table at all, so those posts cannot have come from it), and the reclaim —
-- which gates on the CONDITION rather than its own migration flag — fired a
-- SECOND time and deleted them at 20:39:23Z, appending a second `deleted` to
-- `heartbeat.json.table_retired`. The zero-table window opens at 20:39:23Z.
18 posts across 7 distinct threads, as stamped above. On the kickoff thread of 2026-09-11
(C0BDW7G1Z62 / 1789157069.157999) the one-line pointer landed at
20:05:08 and the eleven-row table at 20:05:54, edited again at 20:08. A
reader saw both — which is precisely the "two renderings of one truth,
edited on different cadences" that phase_table.py's own header spends a page
refusing.
This is the class ADR-0004 exists for: a system nobody catalogued, so nobody
checked it. It was absent from SYSTEMS.md until this ADR's PR.
The decision
1. Slack carries the pointer. The card carries the rows. One message per thread.
operator-heartbeat.py no longer renders a table and no longer owns a Slack
message. Per tick, per live task, it makes exactly one call into agent-smith's
already-landed entry point:
thread_phase_activities.refresh_phases(channel, thread_ts)
Not a second mechanism — the mechanism. That call re-derives the rows once
(phase_table.derive), rewrites phases.json, and publishes the pointer
through thread_anchor.publish_table, the compare-and-write the cutover
reclaims. Because it is the same anchor, a thread agent-smith has already
rendered gets an edit of the existing message (observed: unchanged), and a
thread nothing has rendered — a task started by start-operator --slack-thread,
which writes only task.json.origin and which no reply path ever touches — gets
its pointer minted. No thread ends up with two Smith messages, in either
direction.
2. The ticker survives the table, because the card needed a ticker
This is the part that is not merely a deletion. Before this change phases.json
was written only when a Smith workflow happened to render. An Operator-only task
therefore ran with a stale card: measured 2026-09-11, arch-design-stress-test
was active with a phases.json last written 2026-09-08 19:30. Once ADR-0007
makes the close-up the phase surface, a surface nobody refreshes is the
2026-08-20 incident wearing the new surface's clothes — which
refresh_phases's own docstring predicts in as many words.
So the 120s tick keeps its job and changes what the job is: it feeds the
card. Receipt, first run: arch-design-stress-test → edited, its
phases.json advancing from 2026-09-08T19:30 to 2026-09-11T15:27:56.
3. The task dir's slug is asserted, and a foreign binding is a decline
refresh_phases resolves the task from the thread's evidence row, not from
the task dir the ticker walks. A thread Smith never rendered has no task_slug,
so the log read returns nothing, every row is a gap, and decision D1 suppresses
the post — the thread goes silent, which is the exact failure the heartbeat was
built to prevent, arriving through a new door.
The ticker therefore asserts the binding it already knows via
thread_phase.claim_task_slug — the purpose-built compare-and-write, not
record(task_slug=…), which merges and would overwrite an incumbent and orphan
the Driver already watching the thread. If the thread is bound to a different
slug, the tick declines and logs whose it is. Filing one task's phases under
another task's pointer is the one thing a single anchor must never do.
4. What was lost, named rather than implied
The table carried a footer headline the ticker computed each tick. It is gone
from Slack; render_pointer does not render evidence.headline. Its homes:
| carried | now lives |
|---|---|
| live subagent activity, off the Driver's tmux pane | the /agents/<sessionId> close-up streams the pane (SessionDetailClient.tsx, usePaneStream(row.tmux, row.paneId), transcript-tail fallback when a live session carries no pane stamp) — continuously, not every 120s |
alive / dead |
the same page's roster row (endedOnRoster); acting on a dead Driver is ADR-0003's ladder, which is where it belonged |
last events.jsonl milestone |
events.jsonl, which the relay already walks |
⚠️ The named loss. The ⚠️ "operator tmux not found — it may have ended or been killed" line no longer appears in Slack. A human who only reads the thread is not told; they must open the link. That is the price of one surface, it is recorded here rather than discovered later, and re-enabling it is one render call in one LaunchAgent script.
5. The frozen tables were reclaimed, the dormant ones were not
A message the ticker owned and will never edit again freezes mid-sentence —
"still on it :claude-dancing:" over eleven rows, forever, in a live thread.
Worse than the table was. Two were live at cutover and both were deleted
(slack.delete_message, hard-gated to Smith's own authorship, and it records
the removal in the outbound ledger so the daily review sees a removal, not a
gap).
Seven older heartbeat.json rows still carry a msg_ts. All seven are done
and carry no Slack binding at all, so the ticker cannot reach them — and
should not: _publish_pointer's standing rule is that a dormant thread keeps
the last true thing it said, and back-filling Slack history nobody asked to
change is not a tick's job.
Amendment to ADR-0007
ADR-0007's Consequences section currently reads:
Both surfaces render one derivation. Slack keeps its pinned table (#348 withholds it only when every row is a gap — decision D1 there; the render still happens, because workflows branch on
states/done), and the board now shows the same rows without Slack being the only place they exist.
That clause is superseded as of 2026-09-04 (agent-smith #371) and this ADR.
Slack keeps its pinned message — the anchor, the D1 gate and the
render-always rule are all unchanged — but that message now carries one line
and one /agents link, not the table. ADR-0007 is otherwise unaffected: its
forcing argument for the snapshot transport is what makes deleting the Slack
rows possible, and §3's "the canonical close-up is /agents/[sessionId]"
becomes load-bearing rather than advisory, because it is now the only place the
rows render.
The cutover itself (agent-smith #371, 2026-09-04) was never written down — it is the off-ledger decision this ADR also closes.
Constraints this must not break
- Must not post a second message into a thread. The pointer goes through
thread_anchor.publish_tableor it does not go. A ticker that minted its own message again would recreate the exact defect measured above. - Must not derive a phase. The ticker computes nothing; it calls the one resolver. A second derivation is what ADR-0007 exists to refuse.
- Must not let ledger silence be the only liveness proof. The ledger records
zero
operator-heartbeatrows after this change by design, so silence can no longer distinguish a working retirement from a dead LaunchAgent. The tick prints toscripts/propflow-status/state/operator-heartbeat.log, and that log — not the ledger — is this system's signal inSYSTEMS.md(ADR-0009: a signal must fail when the system fails). ⚠️ The KEYED line is the unconditional per-sweep summary, NOT the per-task line. Two shapes are printed; the catalog'spatternissweep scanned=:2026-09-11 15:59:02 [operator-heartbeat] sweep scanned=3168 bound=11 published=2 declined=0 skipped=0 failed=0 errors=0 unreclaimable=7— once per sweep, written even when nothing needed doing — and2026-09-11 15:39:35 [operator-heartbeat] <slug> state=<state> refresh=<action>— once per task the tick actually TOUCHED, and deliberately not the signal. The per-task shape was drafted first, and keying on it is wrong:_logfires only for a task that is both Slack-bound and non-terminal, so a perfectly healthy ticker whose live tasks have all finished prints nothing and the signal readsdown— and the live count of bound, non-terminal tasks on this box is small enough that this is not hypothetical. That count is a DATED SNAPSHOT rather than a standing fact, so it has exactly one home: theoperator-heartbeatentry inSYSTEMS.md, which carries it with its as-of time. Not restated here, for the same reasonstaleAfterSis not. (Keying on the[operator-heartbeat]prefix is wrong the other way: the tick-failure line carries it too, so the signal would go green against a log of nothing but errors.) The sweep line is the only shape that separates quiet because idle from silent because dead, and it carries the counts that say which. ThestaleAfterSthis earns lives with theoperator-heartbeatentry inSYSTEMS.md; it is stated there once rather than restated here. ⚠️ Whichever line is keyed must START with a local-timeYYYY-MM-DD HH:MM:SSstamp, becauserelay/src/systems/catalog.ts'sevalLogMatchAgeparses a matched line's age with the anchored regex/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/. The first draft logged[operator-heartbeat] 2026-09-11T20:33:32Z …— pattern present, stamp unparseable — which rendersunknown: matched line has no timestamppermanently: a liveness instrument that can never go green, inside the argument for liveness instruments. Caught by reading the evaluator; fixed in dotclaude#45 with a both-directions control (the ISO shape is a demonstrated NO MATCH, the local shape parses). Both shipped shapes above carry it. - Must not pretend the store write is free — it is a NEW cross-process
writer, disclosed.
refresh_phases→_record_and_render→thread_phase.record_scoped, which calls_store.putunconditionally, even on a no-facts write. Before this change the ticker only read that store (thread_phase.get); it now load-modify-saves it every 120s per live thread, from a process that is not the Temporal worker.claim_task_slug's own docstring already names the residual — "_lockis athreading.LockandJsonKVStoreis load-modify-save, so this is a WITHIN-PROCESS mutex… a cross-process write can in principle drop a row" — andpr_event.pyis already such a writer, so the class is pre-existing, not invented here. What IS new is the frequency. This is recorded rather than denied; the durable fix (a file lock onJsonKVStore) is an agent-smith change and is not smuggled into a LaunchAgent script. - Must not sweep dormant tasks. A terminal task gets one final refresh only if this ticker was beating for it; ~3,000 dormant task dirs are not a queue.
Consequences
One Slack message per thread, for real. The last cross-repo caller of the deleted render path is gone;
phase_table.rendernow has no live caller outside pre-cutover Temporal replay.The card stops going stale on Operator-only tasks — a fix this change bought on the way past, and the reason the LaunchAgent is still worth running.
co.propflow.operator-heartbeatis now inSYSTEMS.md. Its absence is the whole reason the cutover missed it; per ADR-0004 the entry ships with this decision, not after it.⚠️ A TERMINAL TASK OFTEN CANNOT SAY SO, AND THAT IS NOW VISIBLE RATHER THAN STAMPED OVER. Found by a fresh-context review of the companion PR, measured on this box. The pointer's terminal glyph comes from
thread_phase.is_terminal=bool(merged_at) or closed, and aclosedevent survivestask_events.scoped_to_threadonly when itsrefcarries this thread's Slack permalink or its recorded PR url. Across 430closedevents in~/.claude/jobs/tasks/*/events.jsonl:refshapecount GitHub url 198 empty 172 other 47 Slack permalink 13 So a PR-less Driver task — the
start-operator --slack-threadcase this tick exists for — reachesdone=Falsealmost every time, and its thread's last word stays 🧵 "still going" after the task finishes.scoped_to_thread's own docstring already flags thisrefassumption as "external to this package… no drift guard pins itsrefshape"; the table above is that assumption being false in production. The ticker now refuses to recordfinalizedover a live-reading pointer and saysTERMINAL-BUT-POINTER-LIVEin its log instead — a §4 refusal to stamp a success the surface does not show. The durable fix is upstream and is NOT in this decision: the Operator layer's close path must write itsclosedevent with--ref <thread permalink>. Named here so it is a known open item rather than a rediscovery.The rollback is one line. Re-enabling the Slack table is restoring one render call in one script. If Gera wants the tick-by-tick prose back, the honest place for it is the card's own live surface, not a second Slack message.