diff --git a/ROADMAP.md b/ROADMAP.md index 7886b109aad..6b26c5dd6eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,6 +80,7 @@ The operator's real TODO, organized (2026-07-22): six numbered items, each groun - [ ] **roadmap-driven dispatch — machinery landed, go-live proven** — gunbc serve on srv1 serves the live roadmap, and POST /dispatch/NODE_ID invokes belt_dispatch_node in-process. The belt observes fail-closed over tmux, reconciles the ready frontier, and spawns a worktree plus a tmux claude session carrying the filled dispatch brief, with typed refusals at every arm. Go-live was proven by execution on srv1: spawn, idempotent re-POST, and a bogus-id refusal, each independently read back. — ⏳ awaiting sign-off - [ ] **re-dispatch after Stop is broken — the dispatch loop is one-shot per node** — Two failure classes, both proven live on srv1. Debris: Stop leaves the worktree and `dispatch/` branch behind, so the next POST is a typed spawn_failed at the git step. Session-id reuse: even after hand-cleaning, claude exits 1 because the deterministic per-node UUID collides with the archived transcript — one-shot at the claude layer too. + - [ ] **make ownership a recorded fact, not an inferred one** — Ownership is INFERRED at observation: the belt reads the pane's process name and judges whether the session is its own. That cannot be made total — the tmux namespace is host-global, so any process running as that user may wear a dispatch name — and it has an open edge (a real claude reports its version string as pane_current_command on macOS). Record ownership at MINT instead: spawn writes the attempt identity plus the pane's (pid, start-time), which no unprivileged process can forge; observation reads it back. Foreign becomes absent-from-the-ledger, not a fingerprint judgement. - [ ] **UI modeling migration — kill the hand layer; unlock the component register; land /sandbox** — Five phases on #7096's wire vocabulary: P0 the component concept (DispatchButton states DERIVED from the wire authority, totality cross-checked both ways) + the scale token axes; P1 string-JS → ONE modeled TsProgram, emitted as a served asset; P2 every px/font through scale vars, proven byte-neutral by execution; P3 presentation from the model; P4 /sandbox at every depth + gallery + live echo arms; P5 the dead belt-A emit DELETED. Plus the D1–D5 anemia dissolution, same PR: MediaType per RFC 9110 · typed HttpStatus · CssLength · the exemplar roster enrolled · the CSS property/selector grain — pure re-grounding, bytes unchanged. - [ ] **until the actuator lands, this sheet is the intake queue** — new work enters as a row here or as a sized lane node with an Accept line, never ad hoc mid-session; dispatch top-down by the numbering; a session that discovers work files a row instead of chasing it. - [ ] **each dispatched item is a real workflow, not a single hop** — Stages: MODEL first (concept/authority delta + acceptance claims), BRIEF generated from row + model with the bar as checkable claims, IMPLEMENT, ANTAGONISTIC REVIEW against the brief — never the PR's self-description — LAND with the verdict updating this sheet. The wall: MERGE IS NOT DONE — a merge certifies the diff is safe; only a review verdict moves status. The MODEL stage is unskippable and first, to the ed25519 citation bar (every referenced upstream entity carries its own authority); a brief without the model stage's output is not dispatchable. @@ -99,6 +100,7 @@ The operator's real TODO, organized (2026-07-22): six numbered items, each groun - [ ] **the DAG progress bar as pure projection** — Verdict-owned done bits, Volume-weighted, rolled up the existing RoadmapEdge fan-in; per-subtree completion with fan-out/fan-in visible. No stored completion percentage anywhere — a derived read on the same tree the belt walks, rendered via the register/component machinery. - [ ] **the typed stage chain, status derived** — ModelArtifact, then Brief generated from row plus model, then Attempt, then Verdict — typed on the carrier. The verdict is the only status mover: done derives from an Accepted verdict, and backing up to an earlier stage is a re-derivation because everything downstream is generated from the model artifact. - [ ] **dispatch-time walls in belt_dispatch_node** — Refuse dispatch on a node without a ModelArtifact; refuse on stale receipts (the freshness check from ts-wf-node-schema); a NeedsRework verdict dispatches a FRESH session carrying brief + prior diff + located debts — never the producing context unwinding its own work. Typed refusal arms on the existing BeltDispatchResult vocabulary, so the wire and UI pick them up for free through the component register. + - [ ] **cross-session messaging — sessions that hand off, not just finish** — Dispatched sessions are mutually blind: each gets a brief at spawn and answers only by landing a branch. The lane needs more — a NeedsRework verdict must reach a fresh session; a session that discovers work must file a row; the page should show what a worker says while it is saying it. The carrier is the durable state the stage chain already needs, read a second way: append-only, typed, attempt-keyed. Not a chat channel — a ledger both sides read, so a message is a FACT with an author and a time. - [ ] **floor lenses over the roadmap value** — Pure readers (the v2 read interface): done-has-verdict; AcceptedWithResidue-has-counted-rows; dispatched-has-model-artifact. **3a · The stabilization loop — why item 3 exists (receipts):** diff --git a/dag/extdeps/tmux/tmux.dag b/dag/extdeps/tmux/tmux.dag index de4bbf373e1..7d160d0507b 100644 --- a/dag/extdeps/tmux/tmux.dag +++ b/dag/extdeps/tmux/tmux.dag @@ -26,6 +26,14 @@ fn shape_tmux_kill_session_argv(session_name: String) -> List { ["kill-session", "-t", session_name] } +data tmux_list_panes_format_note: String = "The pane-fingerprint read (upstream: tmux FORMATS, man tmux — session_name and pane_current_command are cited format variables; pane_current_command is the name of the process running IN the pane, which is exactly the ownership evidence the dispatch lease classification needs). -a lists panes across all sessions in one read. Single-space separator: tmux session names in this system are single tokens (gunbc-dispatch-) and pane_current_command is a process name — the parse splits on the FIRST space, so even a hypothetical spaced command survives as the fingerprint's head token, which is the token the classification compares." + +data tmux_list_panes_format: String = "#\{session_name\} #\{pane_current_command\}" + +fn shape_tmux_list_panes_argv() -> List { + ["list-panes", "-a", "-F", tmux_list_panes_format] +} + service tmux.Session { operation New { input { session_name: String, working_dir: String, inner_argv: List } diff --git a/dag/gunbc/roadmap_authority.dag b/dag/gunbc/roadmap_authority.dag index 78e8fdeebfa..0d7001e6e22 100644 --- a/dag/gunbc/roadmap_authority.dag +++ b/dag/gunbc/roadmap_authority.dag @@ -2322,16 +2322,17 @@ fn task_sheet_lead_lane() -> RoadmapSection { headline: "re-dispatch after Stop is broken — the dispatch loop is one-shot per node", brief: "Two failure classes, both proven live on srv1. Debris: Stop leaves the worktree and `dispatch/` branch behind, so the next POST is a typed spawn_failed at the git step. Session-id reuse: even after hand-cleaning, claude exits 1 because the deterministic per-node UUID collides with the archived transcript — one-shot at the claude layer too.", displaced_cost: "a stopped node becomes dispatchable again without a human cleaning worktrees and branches on srv1", - current_state: "found live 2026-07-23; both classes reproduce by execution; no fix landed", + current_state: "fix on PR #7266 (U1), green by execution; both classes dissolved at the source rather than cleaned up after — awaiting the operator verdict, so NOT done", first_slice: "teardown/spawn own the worktree+branch lifecycle observe-then-decide (the session_lease upsert shape); a worktree with uncommitted work REFUSES removal, never silently deletes", red_control: "re-POST after Stop must not answer spawn_failed; a worktree with uncommitted changes must refuse teardown", out_of_scope: "the sessions panel (PR-B P2a) — this row is only the re-dispatch loop", - handback: "the resume-vs-fresh choice: `--resume ` continues the node's prior work (arguably the right semantic) vs a per-attempt UUID — operator picks; either way a typed observation arm, never a fabricated fresh-spawn", + handback: "the resume-vs-fresh choice is TAKEN as fresh-per-attempt (see the 2026-07-25 update) and stays the operator's to overturn — reversing it is a one-line derivation change, not a redesign", updates: [ TicketUpdate { on: "2026-07-23", note: "both failure classes reproduced live on srv1; dedup shown independent of UUID determinism (observe keys on the tmux session name)" }, + TicketUpdate { on: "2026-07-25", note: "U1 (#7266): dispatch became ATTEMPT-grain — branch, worktree, and claude session-id all derive from content_hash(node_id x clock probed_at), so the debris class cannot collide and the archived-transcript class cannot recur. The tmux session name stays NODE-grain because it is the observe/reconcile key: fresh where debris collides, stable where identity reconciles. The handback is resolved fresh-per-attempt, not --resume: a resumed session inherits the prior attempt's context, which is exactly what ts-wf-belt-refusals rules out for NeedsRework (a fresh session carrying brief + prior diff, never the producing context unwinding its own work) — the two rows would otherwise disagree about the same question" }, ], authored_on: "2026-07-23", - last_verified_on: "2026-07-23", + last_verified_on: "2026-07-25", }, intricacy: IntricacyMedium, volume: VolumeSmall, repo: "gunbc"), ticket_row(id: "ts-ui-model", done: false, t: TicketFields { headline: "UI modeling migration — kill the hand layer; unlock the component register; land /sandbox", @@ -2397,10 +2398,26 @@ fn task_sheet_lead_lane() -> RoadmapSection { authored_on: "", last_verified_on: "", }), + ticket_wi(id: "ts-dispatch-ownership-mint", done: false, t: TicketFields { + headline: "make ownership a recorded fact, not an inferred one", + brief: "Ownership is INFERRED at observation: the belt reads the pane's process name and judges whether the session is its own. That cannot be made total — the tmux namespace is host-global, so any process running as that user may wear a dispatch name — and it has an open edge (a real claude reports its version string as pane_current_command on macOS). Record ownership at MINT instead: spawn writes the attempt identity plus the pane's (pid, start-time), which no unprivileged process can forge; observation reads it back. Foreign becomes absent-from-the-ledger, not a fingerprint judgement.", + displaced_cost: "the fingerprint's unclosable edge — the belt stops guessing whether a session is its own, so the green kill path needs no per-host process-name receipt", + current_state: "U4 (#7266) landed the fingerprint gate: every refusal arm proven live, but the KILL arm compares a process name whose value is host-dependent (the 2.1.220 finding)", + first_slice: "the spawn ledger — what the belt minted, keyed by attempt. This is the same durable state ts-wf-stage-artifacts needs, so the two land on ONE carrier or neither", + red_control: "a hand-made session wearing a dispatch name classifies Foreign with NO fingerprint read at all; a ledger entry whose (pid, start-time) no longer matches classifies Displaced, never Converged", + out_of_scope: "forbidding the name collision itself — the tmux session namespace is not ours to close, and calling that a wall would be the DESIGN section-5 'never' trap", + handback: "whether the belt should own a PRIVATE tmux server (its own socket) instead of sharing the user's — that genuinely narrows the squat surface, but it changes host provisioning, so the operator owns it", + updates: [ + TicketUpdate { on: "2026-07-25", note: "operator question at the U4 stop-button receipt: 'my interest would be to make it impossible for a foreign process to exist here'. Recorded rather than answered by fingerprint tuning, because the honest reading is that the NAME is not ours to own — what can move is the PROOF: ownership recorded at mint is decidable and forgery-resistant where a name comparison is neither" }, + ], + authored_on: "2026-07-25", + last_verified_on: "", + }, intricacy: IntricacyHigh, volume: VolumeMedium, repo: "gunbc"), ], edges: [ RoadmapEdge { child: nid(s: "ts-dispatch-verdict"), parent: nid(s: "ts-dispatch-lifecycle") }, RoadmapEdge { child: nid(s: "ts-dispatch-rework"), parent: nid(s: "ts-dispatch-lifecycle") }, + RoadmapEdge { child: nid(s: "ts-dispatch-ownership-mint"), parent: nid(s: "ts-dispatch-redispatch") }, ], ), section_group( @@ -2553,11 +2570,27 @@ fn task_sheet_lead_lane() -> RoadmapSection { authored_on: "2026-07-22", last_verified_on: "", }, intricacy: IntricacyLow, volume: VolumeSmall, repo: "gunbc"), + ticket_wi(id: "ts-wf-messaging", done: false, t: TicketFields { + headline: "cross-session messaging — sessions that hand off, not just finish", + brief: "Dispatched sessions are mutually blind: each gets a brief at spawn and answers only by landing a branch. The lane needs more — a NeedsRework verdict must reach a fresh session; a session that discovers work must file a row; the page should show what a worker says while it is saying it. The carrier is the durable state the stage chain already needs, read a second way: append-only, typed, attempt-keyed. Not a chat channel — a ledger both sides read, so a message is a FACT with an author and a time.", + displaced_cost: "the operator relaying between sessions by hand — copying a verdict into the next brief, watching a pane to learn what a worker is doing", + current_state: "nothing recorded before this row; TicketUpdate append is the named first API (ticket_write_interface_note) and the U2 sessions panel is the first reader that would render it", + first_slice: "one direction only — worker to ledger to page: a session appends typed TicketUpdates to its own node, /sessions.json carries them, the row renders them. The reverse direction (ledger to a RUNNING worker) waits for a proven reader, because a write nobody reads is the inert-lens lie", + red_control: "an append from a session whose attempt is not the node's current attempt REFUSES — a stale worker cannot write over a newer attempt's record", + out_of_scope: "agent-to-agent chat, and any channel letting a worker mutate the roadmap outside the updates axis", + handback: "whether a worker may file NEW rows (intake) or only append to its own — ts-intake-discipline says work enters through the sheet, and a writing worker is the first thing that could bypass it", + updates: [ + TicketUpdate { on: "2026-07-25", note: "operator question at the U4 receipt: 'i think we'll also need cross session comms/messaging?'. Recorded as a lane row rather than built, because its carrier is the SAME durable state ts-wf-stage-artifacts and ts-dispatch-ownership-mint need — building a messaging channel on its own storage would fork that state three ways before it exists once" }, + ], + authored_on: "2026-07-25", + last_verified_on: "", + }, intricacy: IntricacyHigh, volume: VolumeMedium, repo: "gunbc"), ], edges: [ RoadmapEdge { child: nid(s: "ts-wf-belt-refusals"), parent: nid(s: "ts-wf-stage-artifacts") }, RoadmapEdge { child: nid(s: "ts-wf-belt-refusals"), parent: nid(s: "ts-dispatch-redispatch") }, RoadmapEdge { child: nid(s: "ts-wf-progress"), parent: nid(s: "ts-wf-node-schema") }, + RoadmapEdge { child: nid(s: "ts-wf-messaging"), parent: nid(s: "ts-wf-stage-artifacts") }, ], ), section_group( diff --git a/dag/gunbc/roadmap_belt_actuate.dag b/dag/gunbc/roadmap_belt_actuate.dag index 25520f6b98e..6aec16839e9 100644 --- a/dag/gunbc/roadmap_belt_actuate.dag +++ b/dag/gunbc/roadmap_belt_actuate.dag @@ -27,11 +27,18 @@ import extdeps.languages.json.emit { json_object, json_kv, } +import gunbc.clock_read { clock_now_probed_at } import gunbc.roadmap_dispatch_actuator { HostExecArgv, DispatchLiveSession, DispatchSpawnCommands, dispatch_spawn_commands, + dispatch_attempt_key, + DispatchAttemptRef, dispatch_attempt_refs, git_for_each_ref_dispatch_argv, + TmuxPaneEntry, parse_tmux_panes_output, tmux_list_panes_argv, + tmux_session_observation_with_panes, dispatch_tmux_session_scope, + DispatchStopGate, StopPermitted, StopRefused, dispatch_stop_gate, + TmuxLsEntry, dispatch_tmux_session_name, dispatch_worktree_root, tmux_ls_argv, @@ -202,16 +209,19 @@ fn belt_claude_preflight() -> BeltClaudePreflight { } } -fn belt_actuate_spawn(node: RoadmapNode, session_uuid: String, workdir: String) -> BeltSpawnOutcome { +fn belt_actuate_spawn(node: RoadmapNode, probed_at: String, workdir: String) -> BeltSpawnOutcome { match belt_claude_preflight() { ClaudePreflightRefused { step: st, detail: d } => SpawnFailed { node_id: node.node as String, step: st, detail: d } - ClaudePreflightOk => belt_actuate_spawn_exec(node: node, session_uuid: session_uuid, workdir: workdir) + ClaudePreflightOk => belt_actuate_spawn_exec(node: node, probed_at: probed_at, workdir: workdir) } } -fn belt_actuate_spawn_exec(node: RoadmapNode, session_uuid: String, workdir: String) -> BeltSpawnOutcome { - let cmds = dispatch_spawn_commands(node: node, session_uuid: session_uuid) +fn belt_actuate_spawn_exec(node: RoadmapNode, probed_at: String, workdir: String) -> BeltSpawnOutcome { + let cmds = dispatch_spawn_commands( + node: node, + session_uuid: belt_spawn_session_uuid(node_id: node.node as String, probed_at: probed_at), + attempt_key: dispatch_attempt_key(node_id: node.node, probed_at: probed_at)) match belt_exec_all(cmds: [cmds.worktree_add, cmds.tmux_spawn], workdir: workdir) { ExecOk { stdout: _ } => Spawned { @@ -243,13 +253,17 @@ data belt_actuate_member_shape_note: String = "The reconcile guarantees spawns a data belt_session_uuid_salt: NonEmptyStr = "belt-session-uuid-v1" as NonEmptyStr -data belt_spawn_session_uuid_note: String = "The spawned session name is claude's --session-id, which claude REQUIRES to be a syntactically valid UUID (claude --help; a `belt-` placeholder is rejected). So we derive a DETERMINISTIC UUID from the node_id: two fnv1a64 content hashes (the bare node_id, and node_id combined with belt_session_uuid_salt) give 16 + 16 = 32 lowercase hex chars (content_hash_atom renders {:016x}), reshaped to RFC-4122 v4 (version nibble at index 12 -> '4', variant nibble at index 16 -> '8') and dashed 8-4-4-4-12. Deterministic = the SAME node re-derives the SAME session-id, so an observe/reconcile that keys sessions by name stays idempotent across ticks and process restarts (no random uuidgen, which would spawn a fresh session every tick). Grounded on std.content_hash — the single hashing authority (DESIGN §3 fnv1a64 dual-surface) — not a hand-rolled hash." +data belt_spawn_session_uuid_note: String = "The spawned session name is claude's --session-id, which claude REQUIRES to be a syntactically valid UUID (a `belt-` placeholder is rejected). The UUID is ATTEMPT-scoped (U1 redispatch fix 2026-07-25): derived from (node_id x the spawn's clock observation) — two fnv1a64 content hashes over the stamped id give 16 + 16 = 32 lowercase hex chars, reshaped to RFC-4122 v4 and dashed 8-4-4-4-12. The earlier node-deterministic derivation made every node one-shot at the claude layer: claude refuses a --session-id whose archived transcript exists, so a stopped node could never dispatch again. Reconcile idempotency never keyed on this id — the TMUX SESSION NAME (node-grain, unchanged) is what observe keys on, so no-spawn-per-tick holds exactly as before; per attempt the derivation stays deterministic (same node + same observation = same id), grounded on std.content_hash — the single hashing authority — not uuidgen." -fn belt_uuid_hex32(node_id: String) -> String { +fn belt_uuid_stamped_id(node_id: String, probed_at: String) -> NonEmptyStr { + join([node_id, "\n", probed_at], "") as NonEmptyStr +} + +fn belt_uuid_hex32(node_id: String, probed_at: String) -> String { concat( - content_hash_atom(value: node_id as NonEmptyStr) as String, + content_hash_atom(value: belt_uuid_stamped_id(node_id: node_id, probed_at: probed_at)) as String, content_hash_combine( - left: content_hash_atom(value: node_id as NonEmptyStr), + left: content_hash_atom(value: belt_uuid_stamped_id(node_id: node_id, probed_at: probed_at)), right: content_hash_atom(value: belt_session_uuid_salt), ) as String, ) @@ -275,18 +289,23 @@ fn belt_uuid_dash(v: String) -> String { substring(s: v, start: 20, end: 32))))))))) } -fn belt_spawn_session_uuid(node_id: String) -> String { - belt_uuid_dash(v: belt_uuid_rfc4122(hex: belt_uuid_hex32(node_id: node_id))) +fn belt_spawn_session_uuid(node_id: String, probed_at: String) -> String { + belt_uuid_dash(v: belt_uuid_rfc4122(hex: belt_uuid_hex32(node_id: node_id, probed_at: probed_at))) } fn belt_actuate_spawn_member(m: DispatchMember, workdir: String) -> BeltSpawnOutcome { match m { DesiredMember { node_id: nid, node: node } => - belt_actuate_spawn( - node: node, - session_uuid: belt_spawn_session_uuid(node_id: nid), - workdir: workdir, - ) + match clock_now_probed_at() { + Present { value: probed_at } => + belt_actuate_spawn(node: node, probed_at: probed_at as String, workdir: workdir) + Absent => + SpawnFailed { + node_id: nid, + step: "attempt-clock", + detail: "clock observation refused; an attempt without a stamp would collide with a later one — fail-closed, no unknown-stamped attempts", + } + } ObservedMember { node_id: nid, session: _ } => SpawnFailed { node_id: nid, @@ -507,6 +526,198 @@ fn belt_tick_result_json_value(t: BeltTickResult) -> JsonValue { ]) } +type BeltAttempts + = AttemptsObserved { attempts: List } + | AttemptsRefused { reason: String } + +data belt_attempts_observe_note: String = "U3: the attempts observation mirrors belt_observe's fail-closed grammar — git absent or a non-zero for-each-ref is an UNKNOWABLE attempts set (AttemptsRefused, typed, carries stderr), never widened to zero attempts; exit 0 with empty output IS a genuine zero (for-each-ref's documented no-match behavior). Runs in belt_actuate_workdir — the same repo-root authority the spawn's worktree add uses, because the attempt branches live exactly where spawns mint them." + +fn belt_attempts_observe() -> BeltAttempts { + let cmd = git_for_each_ref_dispatch_argv() + if belt_program_available(program: cmd.program) { + let result = gunbc.WitnessBin.Run( + workdir: belt_actuate_workdir, + bin_path: cmd.program as FilePath, + args: cmd.args, + ) + if result.success { + AttemptsObserved { attempts: dispatch_attempt_refs(git_output: result.stdout) } + } else { + AttemptsRefused { reason: concat("git for-each-ref failed: ", result.stderr) } + } + } else { + AttemptsRefused { reason: "git not found on PATH — the attempts set is unknowable" } + } +} + +fn belt_attempts_json_value(a: BeltAttempts) -> JsonValue { + match a { + AttemptsRefused { reason: r } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: "roadmap-attempts/v1")), + json_kv(key: "attempts_refused", value: json_bool(b: true)), + json_kv(key: "reason", value: json_string(s: r)), + ]) + AttemptsObserved { attempts } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: "roadmap-attempts/v1")), + json_kv(key: "attempts_refused", value: json_bool(b: false)), + json_kv(key: "attempts", value: json_array(elements: attempts |> map(a2 => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: a2.node_id)), + json_kv(key: "branch", value: json_string(s: a2.branch)), + json_kv(key: "attempt_key", value: json_string(s: a2.attempt_key)), + ])))), + ]) + } +} + +fn belt_attempts_observed_json(a: BeltAttempts) -> String { + serialize_json(v: belt_attempts_json_value(a: a)) +} + +fn belt_attempts_http_status(a: BeltAttempts) -> Int { + match a { + AttemptsRefused { reason: _ } => 503 + AttemptsObserved { attempts: _ } => 200 + } +} + +type BeltStopResult + = StopStopped { node_id: String, session_name: String } + | StopRefusedResult { node_id: String, reason: String } + | StopObserveRefused { node_id: String, reason: String } + | StopTeardownFailed { node_id: String, session_name: String, detail: String } + +data belt_stop_note: String = "U4 (stop from the UI): the R5 owned-only teardown as a wire actuation. The decision chain is observation-first and refuses at every unknowable: tmux ls refused → StopObserveRefused (503); pane read refused → the observation stays name-only and the gate refuses fingerprint-unobserved (409) — a pane-read failure NEVER widens into a kill; the gate itself refuses foreign/absent/inaccessible per verdict (409/404); only a Converged-or-Drifted observation reaches tmux kill-session, and a kill that fails is its own typed arm (502). The operator's hand-made sleep session wearing a dispatch name is the standing negative control: it observes Conflict and the button refuses to kill it." + +fn belt_observe_panes() -> List? { + let cmd = tmux_list_panes_argv() + if belt_program_available(program: cmd.program) { + let result = gunbc.WitnessBin.Run( + workdir: belt_observe_workdir, + bin_path: cmd.program as FilePath, + args: cmd.args, + ) + if result.success { + Present { value: parse_tmux_panes_output(output: result.stdout) } + } else { + none + } + } else { + none + } +} + +fn belt_stop_node(node_id: String) -> BeltStopResult { + match belt_observe() { + ObserveRefused { reason: r } => StopObserveRefused { node_id: node_id, reason: r } + Observed { live } => { + let scope = dispatch_tmux_session_scope(node_id: node_id) + let entries = live + |> filter(sess => sess.session_name == (scope.session_name as String)) + |> map(sess => TmuxLsEntry { session_name: sess.session_name, windows: sess.windows }) + let obs = match belt_observe_panes() { + Present { value: panes } => + tmux_session_observation_with_panes(scope: scope, entries: entries, panes: panes) + Absent => + tmux_session_observation_with_panes(scope: scope, entries: entries, panes: []) + } + match dispatch_stop_gate(scope: scope, obs: obs) { + StopRefused { reason: r2 } => StopRefusedResult { node_id: node_id, reason: r2 } + StopPermitted { session_name: sn } => + match belt_actuate_teardown(session_name: sn) { + ReapedSession { session_name: sn2 } => StopStopped { node_id: node_id, session_name: sn2 } + TeardownFailed { session_name: sn3, detail: d } => + StopTeardownFailed { node_id: node_id, session_name: sn3, detail: d } + } + } + } + } +} + +fn belt_stop_result_json_value(r: BeltStopResult) -> JsonValue { + match r { + StopStopped { node_id: nid, session_name: sn } => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: nid)), + json_kv(key: "status", value: json_string(s: "stopped")), + json_kv(key: "session_name", value: json_string(s: sn)), + ]) + StopRefusedResult { node_id: nid, reason: r2 } => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: nid)), + json_kv(key: "status", value: json_string(s: "stop_refused")), + json_kv(key: "reason", value: json_string(s: r2)), + ]) + StopObserveRefused { node_id: nid, reason: r3 } => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: nid)), + json_kv(key: "status", value: json_string(s: "observe_refused")), + json_kv(key: "reason", value: json_string(s: r3)), + ]) + StopTeardownFailed { node_id: nid, session_name: sn, detail: d } => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: nid)), + json_kv(key: "status", value: json_string(s: "teardown_failed")), + json_kv(key: "session_name", value: json_string(s: sn)), + json_kv(key: "detail", value: json_string(s: d)), + ]) + } +} + +fn belt_stop_http_status(r: BeltStopResult) -> Int { + match r { + StopStopped { node_id: _, session_name: _ } => 200 + StopRefusedResult { node_id: _, reason: r2 } => + if string_contains(s: r2, pattern: "no live session") { 404 } else { 409 } + StopObserveRefused { node_id: _, reason: _ } => 503 + StopTeardownFailed { node_id: _, session_name: _, detail: _ } => 502 + } +} + +fn belt_stop_json(node_id: String) -> String { + serialize_json(v: belt_stop_result_json_value(r: belt_stop_node(node_id: node_id))) +} + +data belt_sessions_json_note: String = "U2 (sessions panel v0): the belt observation projected as wire JSON so the PAGE can know what is live without the render losing purity — the healthz digest is taken over the pure page body, so live state must arrive by fetch, exactly as dispatch answers do. The projection is a pure fn over BeltObserve (every arm witnessed on synthetic values); the effectful wrapper only threads belt_observe() in. The refusal arm stays a refusal on the wire (observe_refused true + reason, served 503): an unknowable session set renders loud at the client, never as an empty panel." + +fn belt_sessions_json_value(o: BeltObserve) -> JsonValue { + match o { + ObserveRefused { reason: r } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: "roadmap-sessions/v1")), + json_kv(key: "observe_refused", value: json_bool(b: true)), + json_kv(key: "reason", value: json_string(s: r)), + ]) + Observed { live } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: "roadmap-sessions/v1")), + json_kv(key: "observe_refused", value: json_bool(b: false)), + json_kv(key: "sessions", value: json_array(elements: live |> map(sess => + json_object(members: [ + json_kv(key: "node_id", value: json_string(s: sess.node_id)), + json_kv(key: "session_name", value: json_string(s: sess.session_name)), + ])))), + ]) + } +} + +fn belt_sessions_observed_json(o: BeltObserve) -> String { + serialize_json(v: belt_sessions_json_value(o: o)) +} + +fn belt_sessions_json() -> String { + belt_sessions_observed_json(o: belt_observe()) +} + +fn belt_sessions_http_status(o: BeltObserve) -> Int { + match o { + ObserveRefused { reason: _ } => 503 + Observed { live: _ } => 200 + } +} + fn belt_run_once_json() -> String { serialize_json(v: belt_tick_result_json_value(t: belt_run_once())) } @@ -576,11 +787,16 @@ fn belt_dispatch_node(node_id: String) -> BeltDispatchResult { session_name: dispatch_tmux_session_name(node_id: node.node), } } else { - match belt_actuate_spawn( - node: node, - session_uuid: belt_spawn_session_uuid(node_id: node_id), - workdir: belt_actuate_workdir as String, - ) { + match (match clock_now_probed_at() { + Present { value: probed_at } => + belt_actuate_spawn(node: node, probed_at: probed_at as String, workdir: belt_actuate_workdir as String) + Absent => + SpawnFailed { + node_id: node_id, + step: "attempt-clock", + detail: "clock observation refused; an attempt without a stamp would collide with a later one — fail-closed, no unknown-stamped attempts", + } + }) { Spawned { node_id: sid, session_name: sn } => DispatchSpawned { node_id: sid, session_name: sn } SpawnFailed { node_id: sid, step: st, detail: d } => diff --git a/dag/gunbc/roadmap_component.dag b/dag/gunbc/roadmap_component.dag index 1a2d1355630..7b9cf29b1f7 100644 --- a/dag/gunbc/roadmap_component.dag +++ b/dag/gunbc/roadmap_component.dag @@ -131,6 +131,14 @@ The prefix stays inside the reserved width the button already derives from the l data dispatch_session_chip_prefix: String = "session · " +data sessions_route_path: String = "/sessions.json" + +data attempts_route_path: String = "/attempts.json" + +data dispatch_caption_live_word_note: String = "U2: the observation word for a session the BELT reports live at page load — type-distinct from the wire status words (spawned/already_live are answers to MY click; live is an observed fact about the world). It renders through the same session-chip morph, so the caption stays a projection of what was observed, never a phrase the page invented." + +data dispatch_caption_live_word: String = "live" + data dispatch_class_requested: String = "dispatch-requested" data dispatch_class_ok: String = "dispatch-ok" data dispatch_class_refused: String = "dispatch-refused" @@ -1027,6 +1035,230 @@ fn swatch_toggle_bind_statements() -> List { ] } +data sessions_panel_note: String = "U2 (sessions panel v0): on load the client fetches the belt observation and marks each LIVE node's actuator — ok band + the session-chip morph with the observation word + the tmux session name in the title channel — so a refresh no longer forgets what the belt knows. The refusal arm is LOUD: observe_refused (or a failed fetch) inserts an .observe-refused banner as main's first line naming the reason — an unknowable session set never renders as an empty panel (the altitude law: anomaly expands). The banner is client-inserted so the page render stays pure and the healthz digest law holds." + +fn sessions_mark_live_stmts() -> List { + [ + ts_const(name: "sb", value: ts_call( + callee: ts_member(object: ts_ident(name: "document"), property: "querySelector"), + args: [ts_binop( + left: ts_binop(left: ts_lit_str(value: "[data-node=\""), op: "+", right: ts_member(object: ts_ident(name: "sess"), property: "node_id")), + op: "+", + right: ts_lit_str(value: "\"]"))])), + ts_if( + cond: ts_ident(name: "sb"), + then: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "sb"), property: "textContent"), + op: "=", + right: ts_lit_str(value: concat(dispatch_session_chip_prefix, dispatch_caption_live_word)))), + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_member(object: ts_ident(name: "sb"), property: "classList"), property: "add"), + args: [ts_lit_str(value: dispatch_class_ok)])), + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "sb"), property: "title"), + op: "=", + right: ts_member(object: ts_ident(name: "sess"), property: "session_name"))), + ts_let(name: "sc", value: ts_call( + callee: ts_member(object: ts_member(object: ts_ident(name: "sb"), property: "parentElement"), property: "querySelector"), + args: [ts_lit_str(value: ".stop-chip")])), + ts_if( + cond: ts_prefix(op: "!", expr: ts_ident(name: "sc")), + then: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop(left: ts_ident(name: "sc"), op: "=", right: ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "createElement"), args: [ts_lit_str(value: "button")]))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "className"), op: "=", right: ts_lit_str(value: "stop-chip"))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "type"), op: "=", right: ts_lit_str(value: "button"))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "textContent"), op: "=", right: ts_lit_str(value: "stop"))), + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_ident(name: "sc"), property: "addEventListener"), + args: [ts_lit_str(value: "click"), ts_arrow(params: ["ev"], body: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "textContent"), op: "=", right: ts_lit_str(value: "stopping…"))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "disabled"), op: "=", right: ts_ident(name: "true"))), + ts_expr_stmt(expr: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call(callee: ts_ident(name: "fetch"), args: [ + ts_binop(left: ts_lit_str(value: "/stop/"), op: "+", right: ts_member(object: ts_ident(name: "sess"), property: "node_id")), + ts_object(fields: [ts_field(key: "method", value: ts_lit_str(value: "POST"))]), + ]), + property: "then"), + args: [ts_arrow(params: ["r"], body: ts_block(stmts: [ts_return(expr: ts_call(callee: ts_member(object: ts_ident(name: "r"), property: "json"), args: []))]))]), + property: "then"), + args: [ts_arrow(params: ["j"], body: ts_block(stmts: [ + ts_if( + cond: ts_binop(left: ts_member(object: ts_ident(name: "j"), property: "status"), op: "===", right: ts_lit_str(value: "stopped")), + then: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "window"), property: "location"), property: "reload"), args: [])), + ]), + else_stmt: Present { value: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "textContent"), op: "=", right: ts_lit_str(value: "stop refused"))), + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "sc"), property: "title"), + op: "=", + right: ts_binop( + left: ts_binop(left: ts_member(object: ts_ident(name: "j"), property: "reason"), op: "||", right: ts_member(object: ts_ident(name: "j"), property: "detail")), + op: "||", + right: ts_lit_str(value: "no reason on the wire")))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "disabled"), op: "=", right: ts_ident(name: "false"))), + ]) }, + ), + ]))]), + property: "catch"), + args: [ts_arrow(params: ["e"], body: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "textContent"), op: "=", right: ts_lit_str(value: "stop failed"))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "title"), op: "=", right: ts_binop(left: ts_ident(name: "e"), op: "+", right: ts_lit_str(value: "")))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "sc"), property: "disabled"), op: "=", right: ts_ident(name: "false"))), + ]))])), + ]))])), + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_ident(name: "sb"), property: "insertAdjacentElement"), + args: [ts_lit_str(value: "afterend"), ts_ident(name: "sc")])), + ]), + else_stmt: none, + ), + ]), + else_stmt: none, + ), + ] +} + +fn sessions_refused_banner_stmts() -> List { + [ + ts_const(name: "bn", value: ts_call( + callee: ts_member(object: ts_ident(name: "document"), property: "createElement"), + args: [ts_lit_str(value: "div")])), + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "bn"), property: "className"), + op: "=", + right: ts_lit_str(value: "observe-refused"))), + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "bn"), property: "textContent"), + op: "=", + right: ts_ident(name: "why"))), + ts_const(name: "mn", value: ts_call( + callee: ts_member(object: ts_ident(name: "document"), property: "querySelector"), + args: [ts_lit_str(value: "main")])), + ts_expr_stmt(expr: ts_binop( + left: ts_ident(name: "mn"), + op: "&&", + right: ts_call( + callee: ts_member(object: ts_ident(name: "mn"), property: "prepend"), + args: [ts_ident(name: "bn")]))), + ] +} + +fn sessions_fetch_statements() -> List { + [ + ts_expr_stmt(expr: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call(callee: ts_ident(name: "fetch"), args: [ts_lit_str(value: sessions_route_path)]), + property: "then"), + args: [ts_arrow(params: ["r"], body: ts_block(stmts: [ts_return(expr: ts_call(callee: ts_member(object: ts_ident(name: "r"), property: "json"), args: []))]))]), + property: "then"), + args: [ts_arrow(params: ["sj"], body: ts_block(stmts: [ + ts_if( + cond: ts_member(object: ts_ident(name: "sj"), property: "observe_refused"), + then: ts_block(stmts: concat( + [ts_const(name: "why", value: ts_binop(left: ts_lit_str(value: "session observation refused: "), op: "+", right: ts_binop(left: ts_member(object: ts_ident(name: "sj"), property: "reason"), op: "||", right: ts_lit_str(value: "no reason on the wire"))))], + sessions_refused_banner_stmts())), + else_stmt: Present { value: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_member(object: ts_ident(name: "sj"), property: "sessions"), property: "forEach"), + args: [ts_arrow(params: ["sess"], body: ts_block(stmts: sessions_mark_live_stmts()))])), + ]) }, + ), + ]))]), + property: "catch"), + args: [ts_arrow(params: ["e"], body: ts_block(stmts: concat( + [ts_const(name: "why", value: ts_binop(left: ts_lit_str(value: "session observation refused: "), op: "+", right: ts_binop(left: ts_ident(name: "e"), op: "+", right: ts_lit_str(value: ""))))], + sessions_refused_banner_stmts())))])), + ] +} + +data attempts_row_note: String = "U3 (result surfacing v0): each attempt branch renders into its node's disclosure as a mono line — the branch NAME, copyable and true (a tree link would be dead until a push; a dead link is a fabricated result — the PR half lands when push/PR state is observed, not guessed). Upsert by class so a reload never stacks duplicates; the refusal arm reuses the loud banner with its own message." + +fn attempts_mark_stmts() -> List { + [ + ts_const(name: "ab", value: ts_call( + callee: ts_member(object: ts_ident(name: "document"), property: "querySelector"), + args: [ts_binop( + left: ts_binop(left: ts_lit_str(value: "[data-node=\""), op: "+", right: ts_member(object: ts_ident(name: "att"), property: "node_id")), + op: "+", + right: ts_lit_str(value: "\"]"))])), + ts_const(name: "ali", value: ts_binop( + left: ts_ident(name: "ab"), + op: "&&", + right: ts_call(callee: ts_member(object: ts_ident(name: "ab"), property: "closest"), args: [ts_lit_str(value: "li")]))), + ts_const(name: "adt", value: ts_binop( + left: ts_ident(name: "ali"), + op: "&&", + right: ts_call(callee: ts_member(object: ts_ident(name: "ali"), property: "querySelector"), args: [ts_lit_str(value: "details.item-detail")]))), + ts_if( + cond: ts_ident(name: "adt"), + then: ts_block(stmts: [ + ts_let(name: "al", value: ts_call(callee: ts_member(object: ts_ident(name: "adt"), property: "querySelector"), args: [ts_lit_str(value: ".dispatch-attempts")])), + ts_if( + cond: ts_prefix(op: "!", expr: ts_ident(name: "al")), + then: ts_block(stmts: [ + ts_expr_stmt(expr: ts_binop(left: ts_ident(name: "al"), op: "=", right: ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "createElement"), args: [ts_lit_str(value: "div")]))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "al"), property: "className"), op: "=", right: ts_lit_str(value: "dispatch-attempts"))), + ts_expr_stmt(expr: ts_binop(left: ts_member(object: ts_ident(name: "al"), property: "textContent"), op: "=", right: ts_lit_str(value: "attempts: "))), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "adt"), property: "append"), args: [ts_ident(name: "al")])), + ]), + else_stmt: none, + ), + ts_expr_stmt(expr: ts_binop( + left: ts_member(object: ts_ident(name: "al"), property: "textContent"), + op: "=", + right: ts_binop( + left: ts_binop(left: ts_member(object: ts_ident(name: "al"), property: "textContent"), op: "+", right: ts_member(object: ts_ident(name: "att"), property: "branch")), + op: "+", + right: ts_lit_str(value: " ")))), + ]), + else_stmt: none, + ), + ] +} + +fn attempts_fetch_statements() -> List { + [ + ts_expr_stmt(expr: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call(callee: ts_ident(name: "fetch"), args: [ts_lit_str(value: attempts_route_path)]), + property: "then"), + args: [ts_arrow(params: ["r"], body: ts_block(stmts: [ts_return(expr: ts_call(callee: ts_member(object: ts_ident(name: "r"), property: "json"), args: []))]))]), + property: "then"), + args: [ts_arrow(params: ["aj"], body: ts_block(stmts: [ + ts_if( + cond: ts_member(object: ts_ident(name: "aj"), property: "attempts_refused"), + then: ts_block(stmts: concat( + [ts_const(name: "why", value: ts_binop(left: ts_lit_str(value: "attempt observation refused: "), op: "+", right: ts_binop(left: ts_member(object: ts_ident(name: "aj"), property: "reason"), op: "||", right: ts_lit_str(value: "no reason on the wire"))))], + sessions_refused_banner_stmts())), + else_stmt: Present { value: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_member(object: ts_ident(name: "aj"), property: "attempts"), property: "forEach"), + args: [ts_arrow(params: ["att"], body: ts_block(stmts: attempts_mark_stmts()))])), + ]) }, + ), + ]))]), + property: "catch"), + args: [ts_arrow(params: ["e"], body: ts_block(stmts: concat( + [ts_const(name: "why", value: ts_binop(left: ts_lit_str(value: "attempt observation refused: "), op: "+", right: ts_binop(left: ts_ident(name: "e"), op: "+", right: ts_lit_str(value: ""))))], + sessions_refused_banner_stmts())))])), + ] +} + fn dispatch_client_program_for(selector: String, url_expr: TsExpr, header: List) -> TsProgram { TsProgram { ts_nocheck: false, @@ -1043,7 +1275,11 @@ fn dispatch_client_program_for(selector: String, url_expr: TsExpr, header: List< theme_toggle_bind_statements(), concat( disclosure_bind_statements(), - swatch_toggle_bind_statements())))))), + concat( + swatch_toggle_bind_statements(), + concat( + sessions_fetch_statements(), + attempts_fetch_statements())))))))), } } diff --git a/dag/gunbc/roadmap_dispatch_actuator.dag b/dag/gunbc/roadmap_dispatch_actuator.dag index ed7e5505a83..95b3be06bdb 100644 --- a/dag/gunbc/roadmap_dispatch_actuator.dag +++ b/dag/gunbc/roadmap_dispatch_actuator.dag @@ -1,6 +1,7 @@ module gunbc.roadmap_dispatch_actuator import std.types { String, NonEmptyStr, Int, Bool, List, FilePath, GitRef } +import std.content_hash { content_hash_atom } import std.upsert_decision { ObservationVerdict, Converged, @@ -41,6 +42,7 @@ import extdeps.tmux { shape_tmux_new_session_argv, shape_tmux_ls_argv, shape_tmux_kill_session_argv, + shape_tmux_list_panes_argv, } import extdeps.llm.cli { shape_claude_invoke_argv } import std.disposition { Disposition, Scaffold, RealizationDispatch } @@ -48,13 +50,8 @@ import std.decl_ref { DeclarationRef, WholeDeclaration } import gunbc.host_layout { srv1_dispatch_worktree_root } import gunbc.repo_identity { gunbc_merge_target_ref } -data dispatch_tmux_process_fingerprint_observation_dissolution_trigger: Disposition = Scaffold { - dissolves_to: RealizationDispatch, - bind: DeclarationRef { - module_path: "gunbc.roadmap_dispatch_actuator", - decl_name: "classify_dispatch_lease_upsert", - field: WholeDeclaration - } +data dispatch_tmux_process_fingerprint_observation_dissolution_trigger: Disposition = Terminal { + reason: "DISSOLVED (U4, 2026-07-25): the pane-fingerprint observation landed — tmux list-panes with the cited format variables feeds tmux_session_observation_with_panes, so SessionObserved carries pane_current_command and the lease classification's Converged/Conflict arms are reachable by observation, not only by fixture. The R5 owned-only teardown gate (dispatch_stop_gate) is its first live consumer." as NonEmptyStr } data dispatch_repo: String = "gunbc" @@ -64,6 +61,8 @@ data dispatch_worktree_root_note: String = "PROJECTION of gunbc.host_layout.srv1 data dispatch_worktree_root: String = srv1_dispatch_worktree_root as String data dispatch_git_remote_ref: String = gunbc_merge_target_ref as String data dispatch_tmux_session_prefix: String = "gunbc-dispatch-" +data dispatch_claude_fingerprint_finding_note: String = "NAMED FINDING (U4 live drive, macOS, 2026-07-25): pane_current_command is the kernel process name (p_comm) — symlinks resolve and exec -a cannot fake it, which is exactly the anti-spoof property R5 wants — BUT a REAL running claude CLI on this macOS reported its VERSION STRING (2.1.220) as p_comm, not claude (node process.title). So the Converged green path may not fire on real workers until the fingerprint a real srv1 spawned session reports is READ, not guessed. The refuse arms are unaffected (they are the safety half and were proven live four ways). DECIDE-BY-RECEIPT: run tmux list-panes on srv1 beside a live dispatch session; if it reports claude this row stands; if it reports a version-or-node name, this row becomes the observed value (or an accepted set) — a data edit, never a code change." + data dispatch_claude_process_fingerprint: NonEmptyStr = "claude" as NonEmptyStr data claude_skip_permission_settings_json: String = "{\"skipDangerousModePermissionPrompt\":true}" data dispatch_claude_start_prompt: String = "Begin working on the assigned node." @@ -171,12 +170,18 @@ fn dispatch_tmux_session_name(node_id: RoadmapNodeId) -> String { concat(dispatch_tmux_session_prefix, node_id) } -fn dispatch_worktree_path(node_id: RoadmapNodeId) -> String { - concat(concat(dispatch_worktree_root, "/"), node_id) +data dispatch_attempt_note: String = "Dispatch is ATTEMPT-grain (U1, the redispatch fix, 2026-07-25; the operator's accidental spawn was the live receipt): branch, worktree, and claude session-id carry an attempt key derived from (node_id x the spawn's clock observation) through std.content_hash — so a stopped attempt's debris never collides with the next spawn, and NOTHING is deleted to make way (a stopped attempt's worktree may hold uncommitted work, and the rework discipline carries prior diffs forward; cleanup is separate explicit work, never an absorbing arm on spawn). The tmux session name deliberately STAYS node-grain: it is what observe/reconcile key on, so already-live detection, kill, and tick idempotency are untouched — the invariant remains one live session per node. Same-instant duplicate spawns for one node collide on the attempt key and refuse at the git step, typed — fail-closed, and honestly: a same-second double-press IS a refusal-worthy event. A clock refusal refuses the spawn (step attempt-clock), never an unknown-stamped attempt that would collide later." + +fn dispatch_attempt_key(node_id: RoadmapNodeId, probed_at: String) -> String { + content_hash_atom(value: join([node_id as String, "\n", probed_at], "") as NonEmptyStr) as String +} + +fn dispatch_worktree_path(node_id: RoadmapNodeId, attempt_key: String) -> String { + join([dispatch_worktree_root, "/", node_id as String, "-a", attempt_key], "") } -fn dispatch_branch_name(node_id: RoadmapNodeId) -> String { - concat("dispatch/", node_id) +fn dispatch_branch_name(node_id: RoadmapNodeId, attempt_key: String) -> String { + join(["dispatch/", node_id as String, "-a", attempt_key], "") } fn dispatch_tmux_session_scope(node_id: RoadmapNodeId) -> TmuxSessionScope { @@ -348,7 +353,7 @@ type DispatchSpawnCommands { tmux_spawn: HostExecArgv } -fn dispatch_spawn_commands(node: RoadmapNode, session_uuid: String) -> DispatchSpawnCommands { +fn dispatch_spawn_commands(node: RoadmapNode, session_uuid: String, attempt_key: String) -> DispatchSpawnCommands { let fields = dispatch_brief_fields(node: node) let brief = fill_dispatch_brief(template: dispatch_brief_template, fields: fields) let effort = match node.sizing { @@ -356,8 +361,8 @@ fn dispatch_spawn_commands(node: RoadmapNode, session_uuid: String) -> DispatchS Unsized => "medium" } let session_name = dispatch_tmux_session_name(node_id: node.node) - let worktree_path = dispatch_worktree_path(node_id: node.node) - let branch_name = dispatch_branch_name(node_id: node.node) + let worktree_path = dispatch_worktree_path(node_id: node.node, attempt_key: attempt_key) + let branch_name = dispatch_branch_name(node_id: node.node, attempt_key: attempt_key) let worktree = git_worktree_add_argv( worktree_path: worktree_path, branch_name: branch_name, @@ -527,7 +532,7 @@ fn node_repo_supported(node: RoadmapNode) -> Bool { } } -fn dispatch_permitted_for_node(node: RoadmapNode) -> DispatchRequestGate { +fn dispatch_permitted_for_node(node: RoadmapNode, attempt_key: String) -> DispatchRequestGate { let brief = filled_dispatch_brief_for_node(node: node) let effort = match node.sizing { Sized { intricacy, volume: _, repo: _, acceptance: _ } => @@ -536,8 +541,8 @@ fn dispatch_permitted_for_node(node: RoadmapNode) -> DispatchRequestGate { } DispatchPermitted { session_name: dispatch_tmux_session_name(node_id: node.node), - worktree_path: dispatch_worktree_path(node_id: node.node), - branch_name: dispatch_branch_name(node_id: node.node), + worktree_path: dispatch_worktree_path(node_id: node.node, attempt_key: attempt_key), + branch_name: dispatch_branch_name(node_id: node.node, attempt_key: attempt_key), brief: brief, claude_effort: effort, } @@ -547,6 +552,7 @@ fn classify_dispatch_request( node: RoadmapNode, ready: List, tmux_entries: List, + attempt_key: String, ) -> DispatchRequestGate { if !node_in_ready(node_id: node.node, ready: ready) { DispatchRefused { reason: "node not in ready frontier" as NonEmptyStr } @@ -562,7 +568,7 @@ fn classify_dispatch_request( UpsertClassification { verdict: _, decision: Noop } => DispatchRefused { reason: "dispatch session already running for node" as NonEmptyStr } UpsertClassification { verdict: _, decision: Apply { plan: _ } } => - dispatch_permitted_for_node(node: node) + dispatch_permitted_for_node(node: node, attempt_key: attempt_key) } } } @@ -604,8 +610,132 @@ fn dispatch_live_sessions(tmux_ls_output: String) -> List { entries |> map(e => dispatch_live_session_for_entry(entry: e)) } +data dispatch_attempt_refs_note: String = "U3 (result surfacing): the attempt branches ARE the results ledger — every spawn mints dispatch/-a (U1), so listing refs/heads/dispatch/ observes every attempt ever made, including pre-attempt-grain LEGACY branches (attempt_key empty — the operator's accidental ts-authority-converge spawn is one), which are real attempts and render as such, never filtered as noise. Parse discriminates by the LAST -a segment being exactly 16 chars (the content-hash key width); a node id that itself ends in -a<16 chars> would misparse — accepted and named, not papered over: node ids are roadmap-authored and none has that shape. v0 renders branch NAMES (copyable, true), never tree links that would be dead until a push — a dead link is a fabricated result; the PR half lands when push/PR state is observed, not guessed." + +type DispatchAttemptRef { + node_id: String + branch: String + attempt_key: String +} + +fn parse_dispatch_attempt_branch(line: String) -> DispatchAttemptRef? { + if string_contains(s: line, pattern: "dispatch/") { + let rest = substring(s: line, start: 9, end: string_length(s: line)) + if rest == "" { + none + } else { + let segs = split(s: rest, delimiter: "-a") + let last = segs |> fold(init: "", f: (acc, x) => x) + if count(segs) >= 2 && string_length(s: last) == 16 { + Present { value: DispatchAttemptRef { + node_id: substring(s: rest, start: 0, end: string_length(s: rest) - 18), + branch: line, + attempt_key: last, + } } + } else { + Present { value: DispatchAttemptRef { node_id: rest, branch: line, attempt_key: "" } } + } + } + } else { + none + } +} + +fn dispatch_attempt_refs(git_output: String) -> List { + fold( + split(s: git_output, delimiter: "\n"), + init: [], + f: (acc, line) => match parse_dispatch_attempt_branch(line: line) { + Present { value: r } => append(acc, [r]) + Absent => acc + }, + ) +} + +fn git_for_each_ref_dispatch_argv() -> HostExecArgv { + host_exec_argv(program: "git", args: ["for-each-ref", "--format=%(refname:short)", "refs/heads/dispatch/"]) +} + data dispatch_session_ownership_note: String = "R5 owned-only teardown, session grain: a live dispatch session is teardown-eligible (OUR process) ONLY when its lease verdict is Converged (running with our expected process fingerprint) or Drifted (our fingerprint, stale lease — reap-able stale-ours, matching decision_for_dispatch_lease_verdict's DrainStale). Conflict (foreign process), Inaccessible, UnknownRefused, Absent are NOT owned. Name-only tmux-ls observation yields UnknownRefused/Inaccessible only, so teardown FAIL-CLOSED-REFUSES until the observation carries a process fingerprint — correct R5 (no ownership proof, no reap). This owned-ness judgment lives HERE (the actuator owns lease/ownership classification, §3) as a Bool; gunbc.roadmap_belt lifts the Bool into gunbc.ownership.Ownership for the reconcile teardown gate, so the belt never imports ObservationVerdict." +data tmux_pane_fingerprint_note: String = "U4 (stop from the UI): the pane read is the scaffold's declared dissolution — tmux list-panes -a with the cited format variables yields (session_name, pane_current_command) per pane, and pane_current_command IS the process-fingerprint evidence the lease classification was built to consume. The observation upgrade is total over its inputs: a session whose pane row is found observes as SessionObserved (fingerprint in hand — Converged for claude, Conflict for anything else wearing the name, e.g. a hand-made sleep); a session visible in ls but absent from the pane read stays SessionNameObserved (honest partial — UnknownRefused downstream, still no reap). The stop gate is the R5 table as a typed decision: owned stops; everything else refuses with the verdict's own reason, and refusing to kill what we cannot prove ours is the point, not a limitation." + +type TmuxPaneEntry { + session_name: String + command: String +} + +fn parse_tmux_pane_line(line: String) -> TmuxPaneEntry? { + let segs = split(s: line, delimiter: " ") + if count(segs) >= 2 && segs.first() != "" { + let name = segs.first() + Present { value: TmuxPaneEntry { + session_name: name, + command: substring(s: line, start: string_length(s: name) + 1, end: string_length(s: line)), + } } + } else { + none + } +} + +fn parse_tmux_panes_output(output: String) -> List { + fold( + split(s: output, delimiter: "\n"), + init: [], + f: (acc, line) => match parse_tmux_pane_line(line: line) { + Present { value: e } => append(acc, [e]) + Absent => acc + }, + ) +} + +fn tmux_list_panes_argv() -> HostExecArgv { + host_exec_argv(program: "tmux", args: shape_tmux_list_panes_argv()) +} + +fn pane_command_for_session(panes: List, session_name: String) -> String { + fold(panes, init: "", f: (acc, p) => + if acc == "" && p.session_name == session_name { p.command } else { acc }) +} + +fn tmux_session_observation_with_panes( + scope: TmuxSessionScope, + entries: List, + panes: List, +) -> TmuxSessionObservation { + match tmux_session_observation_for_scope(scope: scope, entries: entries) { + SessionNameObserved { session_name: name, windows: w } => { + let cmd = pane_command_for_session(panes: panes, session_name: name as String) + if cmd == "" { + SessionNameObserved { session_name: name, windows: w } + } else { + SessionObserved { session_name: name, windows: w, process_fingerprint: cmd as NonEmptyStr } + } + } + SessionAbsent => SessionAbsent + SessionObserved { session_name: n2, windows: w2, process_fingerprint: f2 } => + SessionObserved { session_name: n2, windows: w2, process_fingerprint: f2 } + SessionObservedStaleExpected { session_name: n3, windows: w3, process_fingerprint: f3, stale_reason: r3 } => + SessionObservedStaleExpected { session_name: n3, windows: w3, process_fingerprint: f3, stale_reason: r3 } + SessionInaccessible => SessionInaccessible + } +} + +type DispatchStopGate + = StopPermitted { session_name: String } + | StopRefused { reason: String } + +fn dispatch_stop_gate(scope: TmuxSessionScope, obs: TmuxSessionObservation) -> DispatchStopGate { + match classify_tmux_session(scope: scope, obs: obs) { + Converged => StopPermitted { session_name: scope.session_name as String } + Drifted => StopPermitted { session_name: scope.session_name as String } + Absent => StopRefused { reason: "no live session for this node" } + Conflict => StopRefused { reason: "a foreign process wears the dispatch session name — refusing to kill what we cannot prove ours" } + Inaccessible => StopRefused { reason: "tmux session inaccessible" } + UnknownRefused => StopRefused { reason: "process fingerprint unobserved — no ownership proof, no reap" } + } +} + fn dispatch_session_is_owned(session: DispatchLiveSession) -> Bool { match session.lease_verdict { Converged => true @@ -634,8 +764,8 @@ fn argv_contains_token(argv: List, token: String) -> Bool { } fn hostile_node_id_stays_single_argv_token(node_id: String) -> Bool { - let worktree_path = concat(concat(dispatch_worktree_root, "/"), node_id) - let branch_name = concat("dispatch/", node_id) + let worktree_path = dispatch_worktree_path(node_id: node_id, attempt_key: "feedfacefeedface") + let branch_name = dispatch_branch_name(node_id: node_id, attempt_key: "feedfacefeedface") let worktree = git_worktree_add_argv( worktree_path: worktree_path, branch_name: branch_name, diff --git a/dag/gunbc/roadmap_serve.dag b/dag/gunbc/roadmap_serve.dag index 01bde755e21..28386ba4139 100644 --- a/dag/gunbc/roadmap_serve.dag +++ b/dag/gunbc/roadmap_serve.dag @@ -45,6 +45,11 @@ import gunbc.roadmap_belt_actuate { belt_dispatch_result_json_value, belt_dispatch_result_for_label, belt_dispatch_all_status_labels, + BeltObserve, Observed, ObserveRefused, + belt_observe, belt_sessions_observed_json, belt_sessions_http_status, + BeltAttempts, AttemptsObserved, AttemptsRefused, + belt_attempts_observe, belt_attempts_observed_json, belt_attempts_http_status, + BeltStopResult, belt_stop_node, belt_stop_result_json_value, belt_stop_http_status, } data roadmap_serve_note: String = "The gunbc-served dashboard instantiation (docs/plans/gunbc-served-dashboard-design.md; belt B, parent-approved 2026-07-20). H = RoadmapServeHandler: every row is a DynamicRoute whose handler calls the SAME authority the static-site artifacts call (roadmap_page, roadmap_static_site) — the artifact table and this serve table are two projections of one authority set, never two copies of content, and every body is computed at request time so the served page is the LIVE roadmap, not a deploy-frozen snapshot (the section-3 dual-representation cost this module exists to remove). Dispatch: POST /dispatch/NODE_ID invokes gunbc.roadmap_belt_actuate.belt_dispatch_node IN-PROCESS — the single dispatch authority (#6914), no emitted JS, no subprocess exec, no env-var channel. The launch button that POSTs here is transport-agnostic and lands with belt A in roadmap_page (lane agreement 2026-07-20): the page never knows which backend answers." @@ -71,6 +76,9 @@ type RoadmapServeHandler | SandboxPageHandler | SandboxAssetHandler | SandboxEchoHandler + | SessionsJsonHandler + | AttemptsJsonHandler + | BeltStopHandler data roadmap_serve_table_build_note: String = "Fail-closed table construction (review 40286, the §5 catch: the earlier serve_tpl mapped MalformedPathTemplate to an EMPTY template, and an empty template matches exactly '/' — so a typo in any route literal would have silently become a second root matcher, a widen at the very seam §5 forbids). The build is now a typed fold: the FIRST malformed literal refuses the WHOLE table (RoadmapServeTableRefused carries raw/segment/reason — typed, located), and the serve entry answers EVERY request with a 500 naming the broken literal until it is fixed — a loudly broken dashboard, never silently wrong routing. The RED control is witness_malformed_route_literal_refuses_table (a bad literal through the same build fold -> Refused)." @@ -91,11 +99,14 @@ fn roadmap_serve_route_specs() -> List { RoadmapServeRouteSpec { method: GET, raw: "/ROADMAP.md", handler: RoadmapMdHandler }, RoadmapServeRouteSpec { method: GET, raw: "/target/roadmap-dispatch.json", handler: DispatchJsonHandler }, RoadmapServeRouteSpec { method: GET, raw: "/healthz", handler: HealthzHandler }, + RoadmapServeRouteSpec { method: GET, raw: "/sessions.json", handler: SessionsJsonHandler }, + RoadmapServeRouteSpec { method: GET, raw: "/attempts.json", handler: AttemptsJsonHandler }, RoadmapServeRouteSpec { method: GET, raw: roadmap_dispatch_asset_path, handler: DispatchAssetHandler }, RoadmapServeRouteSpec { method: GET, raw: "/sandbox", handler: SandboxPageHandler }, RoadmapServeRouteSpec { method: GET, raw: sandbox_asset_path, handler: SandboxAssetHandler }, RoadmapServeRouteSpec { method: POST, raw: "/sandbox/echo", handler: SandboxEchoHandler }, RoadmapServeRouteSpec { method: POST, raw: "/dispatch/\{node_id\}", handler: BeltDispatchHandler }, + RoadmapServeRouteSpec { method: POST, raw: "/stop/\{node_id\}", handler: BeltStopHandler }, ] } @@ -237,9 +248,47 @@ fn roadmap_serve_invoke(handler: RoadmapServeHandler, params: List serve_page_response(page: sandbox_page()) SandboxAssetHandler => serve_text_response(status: 200, content_type: text_javascript_utf8, body: sandbox_client_js_source()) SandboxEchoHandler => sandbox_echo_response(request: request) + SessionsJsonHandler => serve_sessions_response() + AttemptsJsonHandler => serve_attempts_response() + BeltStopHandler => serve_stop_handler_response(params: params) } } +fn serve_stop_handler_response(params: List) -> ServeHttpResponse { + let node_id = path_param_value(params: params, name: "node_id") + if node_id == "" { + serve_text_response( + status: 400, + content_type: text_plain_utf8, + body: "stop requires a node id path segment (fail-closed; empty binding is unreachable by construction)\n", + ) + } else { + let r = belt_stop_node(node_id: node_id) + serve_text_response( + status: belt_stop_http_status(r: r), + content_type: application_json_utf8, + body: serialize_json(v: belt_stop_result_json_value(r: r))) + } +} + +fn serve_attempts_response() -> ServeHttpResponse { + let a = belt_attempts_observe() + serve_text_response( + status: belt_attempts_http_status(a: a), + content_type: application_json_utf8, + body: belt_attempts_observed_json(a: a)) +} + +data serve_sessions_note: String = "U2: the live-session observation as a wire route (GET /sessions.json), so the client marks live rows without the page render losing purity (the healthz digest is over the pure body; live state arrives by fetch exactly as dispatch answers do). ONE observation feeds both the status and the body — observing twice could disagree between them. Refusal serves 503 with the typed reason: an unknowable session set is loud at the client, never an empty panel." + +fn serve_sessions_response() -> ServeHttpResponse { + let o = belt_observe() + serve_text_response( + status: belt_sessions_http_status(o: o), + content_type: application_json_utf8, + body: belt_sessions_observed_json(o: o)) +} + fn http_method_label_join(methods: List) -> String { join(map(methods, m => match m { GET => "GET" diff --git a/dag/gunbc/roadmap_style.dag b/dag/gunbc/roadmap_style.dag index b9d36ba00f7..57073d9459b 100644 --- a/dag/gunbc/roadmap_style.dag +++ b/dag/gunbc/roadmap_style.dag @@ -583,6 +583,33 @@ fn roadmap_instance_rules_head() -> List { role_decl(prop: Color, r: TextDimRole), decl(prop: MarginRight, value: scale_var(t: space_2)), ] }, + StaticRule { selector: ClassSelector { name: "stop-chip" }, decls: [ + decl(prop: FontSize, value: scale_var(t: text_10)), + decl(prop: FontFamily, value: scale_var(t: font_mono)), + role_decl(prop: Color, r: TextDimRole), + role_decl(prop: Background, r: SurfaceRole), + decl(prop: Border, value: scale_border(w: border_1, color_var: "var(--border)")), + decl(prop: BorderRadius, value: scale_var(t: radius_3)), + decl(prop: Padding, value: scale_pair(a: space_1, b: space_6)), + decl(prop: MarginLeft, value: scale_var(t: space_2)), + decl(prop: Cursor, value: "pointer"), + decl(prop: TextTransform, value: "lowercase"), + decl(prop: LetterSpacing, value: "0.03em"), + ] }, + StaticRule { selector: ClassSelector { name: "dispatch-attempts" }, decls: [ + decl(prop: FontSize, value: scale_var(t: text_11)), + decl(prop: FontFamily, value: scale_var(t: font_mono)), + role_decl(prop: Color, r: TextDimRole), + decl(prop: Margin, value: scale_triple(a: space_2, b: space_0, c: space_1)), + ] }, + StaticRule { selector: ClassSelector { name: "observe-refused" }, decls: [ + decl(prop: FontSize, value: scale_var(t: text_12)), + role_decl(prop: Color, r: TextRole), + decl(prop: BorderLeft, value: scale_border(w: border_2, color_var: "var(--band-loud)")), + decl(prop: PaddingLeft, value: scale_var(t: space_6)), + decl(prop: Margin, value: scale_triple(a: space_4, b: space_0, c: space_8)), + decl(prop: FontFamily, value: scale_var(t: font_mono)), + ] }, StaticRule { selector: ClassSelector { name: "disclosure-reason" }, decls: [ decl(prop: FontSize, value: scale_var(t: text_12)), role_decl(prop: Color, r: TextRole), diff --git a/dag/test/claim/design_register_lift_parity_witness_test.dag b/dag/test/claim/design_register_lift_parity_witness_test.dag index 8506b6091ce..caab04c3556 100644 --- a/dag/test/claim/design_register_lift_parity_witness_test.dag +++ b/dag/test/claim/design_register_lift_parity_witness_test.dag @@ -36,7 +36,13 @@ data roadmap_css_repin_anomaly_evidence_note: String = "Re-pinned for compositio data roadmap_css_repin_gutter_split_note: String = "Re-pinned for the operator's pass flips (2026-07-25, signed at the pass on #7234's live page): the row's column gutter splits from the badge-flow gutter — the node-head grid gap moves to space-8 (roadmap_row_column_gutter) while node-mid's flow keeps space-4. One decl's value changes; the row witness's expected head block moved with it (it reds on this change by design). Derived by execution; this digest only pins bytes." -data roadmap_css_lift_parity_digest: String = "93b584dfe4efeb0f" +data roadmap_css_repin_sessions_note: String = "Re-pinned for U2 (sessions panel v0): one new rule — .observe-refused, the client-inserted loud banner for a refused session observation (loud's own hue as the edge mark, mono, tokened lengths — the disclosure-reason family). Derived by execution on the merged tree; this digest only pins bytes." + +data roadmap_css_repin_attempts_note: String = "Re-pinned for U3 (result surfacing v0): one new rule — .dispatch-attempts, the mono dim attempts line in the row disclosure. Derived by execution; this digest only pins bytes." + +data roadmap_css_repin_stop_note: String = "Re-pinned for U4 (stop from the UI): one new rule — .stop-chip, the toggle-chip-family action control beside a live session pill. Derived by execution; this digest only pins bytes." + +data roadmap_css_lift_parity_digest: String = "28c06723684117e4" data moodboard_thesis_themes_lift_parity_digest: String = "69e68066c49667f9" fn emission_content_digest(s: String) -> String { diff --git a/dag/test/claim/dispatch_attempts_witness_test.dag b/dag/test/claim/dispatch_attempts_witness_test.dag new file mode 100644 index 00000000000..63c3be6d75d --- /dev/null +++ b/dag/test/claim/dispatch_attempts_witness_test.dag @@ -0,0 +1,110 @@ +module test.claim.dispatch_attempts_witness + +import gunbc.roadmap_belt_actuate { + BeltAttempts, AttemptsObserved, AttemptsRefused, + belt_attempts_json_value, belt_attempts_observed_json, belt_attempts_http_status, + belt_spawn_session_uuid, +} +import gunbc.roadmap_dispatch_actuator { + DispatchAttemptRef, parse_dispatch_attempt_branch, dispatch_attempt_refs, + git_for_each_ref_dispatch_argv, dispatch_attempt_key, dispatch_branch_name, + dispatch_worktree_path, +} +import gunbc.roadmap_component { dispatch_client_js_source } +import gunbc.roadmap_style { roadmap_css } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data dispatch_attempts_witness_note: String = "U3's receipts: the attempt-branch parse proven on real shapes (an attempt-grain branch round-trips node and key; a LEGACY pre-attempt-grain branch — the operator's accidental spawn class — parses with an empty key rather than filtering as noise; garbage refuses), the wire projection proven on synthetic observations both arms, and the client machinery pinned in the served asset. The round-trip witness closes the loop with U1: a branch MINTED by dispatch_branch_name must parse back to its own node and key — the two authorities cannot drift." + +fn attempt_fixture() -> DispatchAttemptRef { + DispatchAttemptRef { node_id: "5-regen-cutover", branch: "dispatch/5-regen-cutover-afeedfacefeedface", attempt_key: "feedfacefeedface" } +} + +test fn attempts_parse_roundtrips_the_minted_branch() -> Bool { + let k = dispatch_attempt_key(node_id: "ts-wf-progress", probed_at: "2026-07-25T22:00:00Z") + let b = dispatch_branch_name(node_id: "ts-wf-progress", attempt_key: k) + return match parse_dispatch_attempt_branch(line: b) { + Present { value: r } => r.node_id == "ts-wf-progress" && r.attempt_key == k && r.branch == b + Absent => false + } +} + +test fn attempts_parse_holds_on_the_three_shapes() -> Bool { + let modern = parse_dispatch_attempt_branch(line: "dispatch/5-regen-cutover-afeedfacefeedface") + let legacy = parse_dispatch_attempt_branch(line: "dispatch/ts-authority-converge") + let garbage = parse_dispatch_attempt_branch(line: "refs/stash") + return match modern { + Present { value: m } => m.node_id == "5-regen-cutover" && m.attempt_key == "feedfacefeedface" + Absent => false + } && match legacy { + Present { value: l } => l.node_id == "ts-authority-converge" && l.attempt_key == "" + Absent => false + } && match garbage { + Present { value: g2 } => false + Absent => true + } +} + +test fn attempts_multi_line_output_parses() -> Bool { + let out = "dispatch/node-x-afeedfacefeedface\ndispatch/ts-authority-converge\n" + let refs = dispatch_attempt_refs(git_output: out) + return count(refs) == 2 + && any(refs, r => r.node_id == "node-x" && r.attempt_key == "feedfacefeedface") + && any(refs, r => r.node_id == "ts-authority-converge" && r.attempt_key == "") +} + +test fn attempts_observed_projects_to_wire() -> Bool { + let j = belt_attempts_observed_json(a: AttemptsObserved { attempts: [attempt_fixture()] }) + return string_contains(s: j, pattern: "\"schema\": \"roadmap-attempts/v1\"") + && string_contains(s: j, pattern: "\"attempts_refused\": false") + && string_contains(s: j, pattern: "\"branch\": \"dispatch/5-regen-cutover-afeedfacefeedface\"") + && belt_attempts_http_status(a: AttemptsObserved { attempts: [] }) == 200 +} + +test fn attempts_refusal_is_loud_on_the_wire() -> Bool { + let a = AttemptsRefused { reason: "git not found on PATH — the attempts set is unknowable" } + let j = belt_attempts_observed_json(a: a) + return string_contains(s: j, pattern: "\"attempts_refused\": true") + && string_contains(s: j, pattern: "unknowable") + && belt_attempts_http_status(a: a) == 503 + && !string_contains(s: j, pattern: "\"attempts\":") +} + +test fn attempts_client_renders_and_shouts() -> Bool { + let js = dispatch_client_js_source() + return string_contains(s: js, pattern: "fetch('/attempts.json')") + && string_contains(s: js, pattern: "aj.attempts_refused") + && string_contains(s: js, pattern: "'dispatch-attempts'") + && string_contains(s: js, pattern: "att.branch") + && string_contains(s: js, pattern: "attempt observation refused: ") + && string_contains(s: js, pattern: "session observation refused: ") +} + +test fn attempts_row_is_styled() -> Bool { + return string_contains(s: roadmap_css(), pattern: ".dispatch-attempts { font-size: var(--text-11); font-family: var(--font-mono); color: var(--text-dim); margin: var(--space-2) var(--space-0) var(--space-1) }") +} + +test fn attempts_argv_is_typed_and_scoped() -> Bool { + let cmd = git_for_each_ref_dispatch_argv() + return cmd.program == "git" + && any(cmd.args, a => a == "refs/heads/dispatch/") + && !any(cmd.args, a => a == "-rf") +} + +data attempt_grain_note: String = "U1's own REDs: redispatch works exactly when a NEW attempt yields a NEW branch/worktree/uuid while the tmux name — the observe/reconcile key — stays node-grain. Fresh where debris collides, stable where identity reconciles. Homed beside U3's parse witnesses because the two are the same authority read in both directions: this row proves the MINT varies per attempt, attempts_parse_roundtrips_the_minted_branch proves the READ recovers exactly what was minted." + +test fn belt_attempt_grain_holds() -> Bool { + let n = "5-regen-cutover" + let t1 = "2026-07-25T20:00:00Z" + let t2 = "2026-07-25T21:30:00Z" + let k1 = dispatch_attempt_key(node_id: n, probed_at: t1) + let k2 = dispatch_attempt_key(node_id: n, probed_at: t2) + return k1 != k2 + && k1 == dispatch_attempt_key(node_id: n, probed_at: t1) + && dispatch_branch_name(node_id: n, attempt_key: k1) != dispatch_branch_name(node_id: n, attempt_key: k2) + && dispatch_worktree_path(node_id: n, attempt_key: k1) != dispatch_worktree_path(node_id: n, attempt_key: k2) + && belt_spawn_session_uuid(node_id: n, probed_at: t1) != belt_spawn_session_uuid(node_id: n, probed_at: t2) + && string_contains(s: dispatch_branch_name(node_id: n, attempt_key: k1), pattern: "dispatch/5-regen-cutover-a") +} diff --git a/dag/test/claim/dispatch_stop_witness_test.dag b/dag/test/claim/dispatch_stop_witness_test.dag new file mode 100644 index 00000000000..44a44f4e447 --- /dev/null +++ b/dag/test/claim/dispatch_stop_witness_test.dag @@ -0,0 +1,102 @@ +module test.claim.dispatch_stop_witness + +import gunbc.roadmap_dispatch_actuator { + TmuxSessionScope, TmuxLsEntry, TmuxPaneEntry, + parse_tmux_pane_line, parse_tmux_panes_output, tmux_list_panes_argv, + tmux_session_observation_with_panes, dispatch_tmux_session_scope, + DispatchStopGate, StopPermitted, StopRefused, dispatch_stop_gate, + dispatch_tmux_process_fingerprint_observation_dissolution_trigger, +} +import gunbc.roadmap_belt_actuate { + BeltStopResult, StopStopped, StopRefusedResult, StopObserveRefused, StopTeardownFailed, + belt_stop_result_json_value, belt_stop_http_status, +} +import gunbc.roadmap_component { dispatch_client_js_source } +import gunbc.roadmap_style { roadmap_css } +import std.disposition { Disposition, Scaffold, Terminal } +import extdeps.languages.json.emit { serialize_json } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data dispatch_stop_witness_note: String = "U4's receipts: the pane parse, the fingerprint-carrying observation upgrade, and the R5 stop gate proven arm by arm — the star RED is the operator's own test fixture (a hand-made sleep session wearing the dispatch name observes Conflict and the gate REFUSES to kill it: refusing what we cannot prove ours is the point). The wire mapping discriminates 404 (no session) from 409 (ownership refusal) from 503 (unknowable) from 502 (kill failed); the client chip is pinned in the served asset; and the fingerprint scaffold's dissolution is asserted discharged." + +fn scope() -> TmuxSessionScope { + dispatch_tmux_session_scope(node_id: "ts-authority-converge") +} + +fn entry() -> TmuxLsEntry { + TmuxLsEntry { session_name: "gunbc-dispatch-ts-authority-converge", windows: 1 } +} + +test fn stop_pane_parse_holds() -> Bool { + let one = parse_tmux_pane_line(line: "gunbc-dispatch-ts-authority-converge sleep") + let multi = parse_tmux_panes_output(output: "main zsh\ngunbc-dispatch-x claude\n") + return match one { + Present { value: p } => p.session_name == "gunbc-dispatch-ts-authority-converge" && p.command == "sleep" + Absent => false + } + && count(multi) == 2 + && any(multi, p => p.session_name == "gunbc-dispatch-x" && p.command == "claude") + && match parse_tmux_pane_line(line: "") { Present { value: p2 } => false Absent => true } +} + +test fn stop_gate_kills_only_proven_claude() -> Bool { + let claude_obs = tmux_session_observation_with_panes( + scope: scope(), entries: [entry()], + panes: [TmuxPaneEntry { session_name: "gunbc-dispatch-ts-authority-converge", command: "claude" }]) + let sleep_obs = tmux_session_observation_with_panes( + scope: scope(), entries: [entry()], + panes: [TmuxPaneEntry { session_name: "gunbc-dispatch-ts-authority-converge", command: "sleep" }]) + let blind_obs = tmux_session_observation_with_panes(scope: scope(), entries: [entry()], panes: []) + let absent_obs = tmux_session_observation_with_panes(scope: scope(), entries: [], panes: []) + return match dispatch_stop_gate(scope: scope(), obs: claude_obs) { + StopPermitted { session_name: sn } => sn == "gunbc-dispatch-ts-authority-converge" + StopRefused { reason: _ } => false + } && match dispatch_stop_gate(scope: scope(), obs: sleep_obs) { + StopPermitted { session_name: _ } => false + StopRefused { reason: r } => string_contains(s: r, pattern: "foreign process") + } && match dispatch_stop_gate(scope: scope(), obs: blind_obs) { + StopPermitted { session_name: _ } => false + StopRefused { reason: r2 } => string_contains(s: r2, pattern: "fingerprint unobserved") + } && match dispatch_stop_gate(scope: scope(), obs: absent_obs) { + StopPermitted { session_name: _ } => false + StopRefused { reason: r3 } => string_contains(s: r3, pattern: "no live session") + } +} + +test fn stop_wire_statuses_discriminate() -> Bool { + let stopped = StopStopped { node_id: "n", session_name: "gunbc-dispatch-n" } + let foreign = StopRefusedResult { node_id: "n", reason: "a foreign process wears the dispatch session name — refusing to kill what we cannot prove ours" } + let missing = StopRefusedResult { node_id: "n", reason: "no live session for this node" } + let unknowable = StopObserveRefused { node_id: "n", reason: "tmux ls: permission denied" } + let failed = StopTeardownFailed { node_id: "n", session_name: "gunbc-dispatch-n", detail: "kill-session exited 1" } + return belt_stop_http_status(r: stopped) == 200 + && belt_stop_http_status(r: foreign) == 409 + && belt_stop_http_status(r: missing) == 404 + && belt_stop_http_status(r: unknowable) == 503 + && belt_stop_http_status(r: failed) == 502 + && string_contains(s: serialize_json(v: belt_stop_result_json_value(r: stopped)), pattern: "\"status\": \"stopped\"") + && string_contains(s: serialize_json(v: belt_stop_result_json_value(r: foreign)), pattern: "\"status\": \"stop_refused\"") +} + +test fn stop_client_carries_the_chip() -> Bool { + let js = dispatch_client_js_source() + return string_contains(s: js, pattern: "'stop-chip'") + && string_contains(s: js, pattern: "'/stop/' + sess.node_id") + && string_contains(s: js, pattern: "{ method: 'POST' }") + && string_contains(s: js, pattern: "j.status === 'stopped'") + && string_contains(s: js, pattern: "window.location.reload()") + && string_contains(s: js, pattern: "'stop refused'") +} + +test fn stop_chip_is_styled() -> Bool { + return string_contains(s: roadmap_css(), pattern: ".stop-chip { font-size: var(--text-10);") +} + +test fn stop_fingerprint_scaffold_is_discharged() -> Bool { + return match dispatch_tmux_process_fingerprint_observation_dissolution_trigger { + Scaffold { dissolves_to: _, bind: _ } => false + Terminal { reason: r } => string_contains(s: r as String, pattern: "DISSOLVED") + } +} diff --git a/dag/test/claim/roadmap_belt_actuate_witness_test.dag b/dag/test/claim/roadmap_belt_actuate_witness_test.dag index f57757eaecb..c6940bd9262 100644 --- a/dag/test/claim/roadmap_belt_actuate_witness_test.dag +++ b/dag/test/claim/roadmap_belt_actuate_witness_test.dag @@ -10,6 +10,7 @@ import gunbc.roadmap_model { } import gunbc.roadmap_status { DerivableLine, Unsigned } import gunbc.roadmap_dispatch_actuator { + dispatch_tmux_session_name, HostExecArgv, host_exec_argv, DispatchLiveSession, @@ -233,19 +234,19 @@ fn uuid_char(u: String, i: Int) -> String { } fn witness_uuid_deterministic() -> Bool { - let a = belt_spawn_session_uuid(node_id: "5-regen-cutover") - let b = belt_spawn_session_uuid(node_id: "5-regen-cutover") + let a = belt_spawn_session_uuid(node_id: "5-regen-cutover", probed_at: "2026-07-25T20:00:00Z") + let b = belt_spawn_session_uuid(node_id: "5-regen-cutover", probed_at: "2026-07-25T20:00:00Z") a == b } fn witness_uuid_distinct_nodes() -> Bool { - let a = belt_spawn_session_uuid(node_id: "node-a") - let b = belt_spawn_session_uuid(node_id: "node-b") + let a = belt_spawn_session_uuid(node_id: "node-a", probed_at: "2026-07-25T20:00:00Z") + let b = belt_spawn_session_uuid(node_id: "node-b", probed_at: "2026-07-25T20:00:00Z") !(a == b) } fn witness_uuid_rfc4122_shape() -> Bool { - let u = belt_spawn_session_uuid(node_id: "5-regen-cutover") + let u = belt_spawn_session_uuid(node_id: "5-regen-cutover", probed_at: "2026-07-25T20:00:00Z") string_length(s: u) == 36 && uuid_char(u: u, i: 8) == "-" && uuid_char(u: u, i: 13) == "-" @@ -483,6 +484,8 @@ fn witness_preflight_ok_on_enriched_entry() -> Bool { )) } +data attempt_grain_home_note: String = "The attempt-grain claim (U1) is PURE — content hashes and string derivations, no host reach — so it lives in dag/test/claim/dispatch_attempts_witness_test.dag, which discovery scans on every affected PR. This file is BinWitnessWet-excluded because its witnesses execute real host commands; a pure row parked here is enrolled with zero executing consumers, which is exactly what the Phase 0(b) admission invariant refused (CI 30184138800). Deferral is a property of the EXECUTION the row needs, never of the module it happens to name." + test fn roadmap_belt_actuate_witnesses() -> Bool { witness_exec_ok_runs_echo() && witness_exec_refuses_missing_program() diff --git a/dag/test/claim/roadmap_dispatch_actuator_witness_test.dag b/dag/test/claim/roadmap_dispatch_actuator_witness_test.dag index 3d0ba3bd3b9..16671938be8 100644 --- a/dag/test/claim/roadmap_dispatch_actuator_witness_test.dag +++ b/dag/test/claim/roadmap_dispatch_actuator_witness_test.dag @@ -164,7 +164,7 @@ fn witness_not_ready_node_refused() -> Bool { let edges = [RoadmapEdge { child: nid(s: "child"), parent: nid(s: "parent") }] let doc = fixture_doc(nodes: [parent, child], edges: edges) let ready = next_spawnable(doc: doc, merged: []) - match classify_dispatch_request(node: child, ready: ready, tmux_entries: []) { + match classify_dispatch_request(node: child, ready: ready, tmux_entries: [], attempt_key: "feedfacefeedface") { DispatchRefused { reason: r } => string_contains(s: r as String, pattern: "not in ready frontier") _ => false } @@ -174,11 +174,11 @@ fn witness_ready_node_permitted_without_live_session() -> Bool { let node = sized_derivable(id: "ready-node", prs: [10], title: "Ready") let doc = fixture_doc(nodes: [node], edges: []) let ready = next_spawnable(doc: doc, merged: []) - match classify_dispatch_request(node: node, ready: ready, tmux_entries: []) { + match classify_dispatch_request(node: node, ready: ready, tmux_entries: [], attempt_key: "feedfacefeedface") { DispatchPermitted { session_name: s, worktree_path: w, branch_name: b, brief: br, claude_effort: e } => s == "gunbc-dispatch-ready-node" - && string_contains(s: w, pattern: "ready-node") - && b == "dispatch/ready-node" + && string_contains(s: w, pattern: "ready-node-afeedfacefeedface") + && b == "dispatch/ready-node-afeedfacefeedface" && string_contains(s: br, pattern: "Ready") && e == "high" _ => false @@ -190,7 +190,7 @@ fn witness_live_lease_refused() -> Bool { let doc = fixture_doc(nodes: [node], edges: []) let ready = next_spawnable(doc: doc, merged: []) let entries = [TmuxLsEntry { session_name: "gunbc-dispatch-leased-node", windows: 1 }] - match classify_dispatch_request(node: node, ready: ready, tmux_entries: entries) { + match classify_dispatch_request(node: node, ready: ready, tmux_entries: entries, attempt_key: "feedfacefeedface") { DispatchRefused { reason: r } => string_contains(s: r as String, pattern: "name occupied") && string_contains(s: r as String, pattern: "fingerprint unobserved") @@ -252,8 +252,8 @@ fn witness_foreign_process_fingerprint_is_conflict() -> Bool { fn witness_fingerprint_scaffold_marker_present() -> Bool { match dispatch_tmux_process_fingerprint_observation_dissolution_trigger { - Scaffold { dissolves_to: _, bind: _ } => true - Terminal { reason: _ } => false + Scaffold { dissolves_to: _, bind: _ } => false + Terminal { reason: r } => string_contains(s: r as String, pattern: "DISSOLVED") } } diff --git a/dag/test/claim/roadmap_page_witness_test.dag b/dag/test/claim/roadmap_page_witness_test.dag index 2498ed89bbc..002042b4732 100644 --- a/dag/test/claim/roadmap_page_witness_test.dag +++ b/dag/test/claim/roadmap_page_witness_test.dag @@ -1,5 +1,7 @@ module test.claim.roadmap_page_witness +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + import std.markup { Fragment, Element, FragmentText, attr } import extdeps.languages.html { try_serialize_html_source, EmitOk, EmitRejected } import gunbc.roadmap_page { diff --git a/dag/test/claim/roadmap_serve_witness_test.dag b/dag/test/claim/roadmap_serve_witness_test.dag index 217d80712d4..97e7579372b 100644 --- a/dag/test/claim/roadmap_serve_witness_test.dag +++ b/dag/test/claim/roadmap_serve_witness_test.dag @@ -122,7 +122,7 @@ fn witness_dispatch_status_total() -> Bool { fn witness_live_table_builds_ready_ten_routes() -> Bool { match roadmap_serve_route_table_build() { - RoadmapServeTable { table: t } => count(t.routes) == 10 + RoadmapServeTable { table: t } => count(t.routes) == 13 RoadmapServeTableRefused { raw: _, segment: _, reason: _ } => false } } diff --git a/dag/test/claim/sessions_panel_witness_test.dag b/dag/test/claim/sessions_panel_witness_test.dag new file mode 100644 index 00000000000..cfa9d882bf3 --- /dev/null +++ b/dag/test/claim/sessions_panel_witness_test.dag @@ -0,0 +1,58 @@ +module test.claim.sessions_panel_witness + +import gunbc.roadmap_belt_actuate { + BeltObserve, Observed, ObserveRefused, + belt_sessions_json_value, belt_sessions_observed_json, belt_sessions_http_status, +} +import gunbc.roadmap_dispatch_actuator { DispatchLiveSession } +import gunbc.roadmap_component { dispatch_client_js_source, sessions_route_path } +import gunbc.roadmap_style { roadmap_css } +import std.upsert_decision { ObservationVerdict, Converged } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data sessions_panel_witness_note: String = "U2's receipts: the observation-to-wire projection proven on synthetic BeltObserve values (every arm — the effectful wrapper only threads belt_observe in), the refusal arm proven LOUD end to end (503 on the wire; the banner machinery in the served asset), and the live-mark path pinned in the emitted client. The RED feeds the projection a refusal and asserts it can never serve 200 or an empty sessions list dressed as success." + +fn fixture_live() -> DispatchLiveSession { + DispatchLiveSession { + session_name: "gunbc-dispatch-5-regen-cutover", + node_id: "5-regen-cutover", + windows: 1, + lease_verdict: Converged, + } +} + +test fn sessions_observed_projects_to_wire() -> Bool { + let j = belt_sessions_observed_json(o: Observed { live: [fixture_live()] }) + return string_contains(s: j, pattern: "\"schema\": \"roadmap-sessions/v1\"") + && string_contains(s: j, pattern: "\"observe_refused\": false") + && string_contains(s: j, pattern: "\"node_id\": \"5-regen-cutover\"") + && string_contains(s: j, pattern: "\"session_name\": \"gunbc-dispatch-5-regen-cutover\"") + && belt_sessions_http_status(o: Observed { live: [fixture_live()] }) == 200 +} + +test fn sessions_refusal_is_loud_on_the_wire() -> Bool { + let o = ObserveRefused { reason: "tmux ls: permission denied" } + let j = belt_sessions_observed_json(o: o) + return string_contains(s: j, pattern: "\"observe_refused\": true") + && string_contains(s: j, pattern: "tmux ls: permission denied") + && belt_sessions_http_status(o: o) == 503 + && !string_contains(s: j, pattern: "\"sessions\"") +} + +test fn sessions_client_marks_live_and_shouts_refusal() -> Bool { + let js = dispatch_client_js_source() + return string_contains(s: js, pattern: "fetch('/sessions.json')") + && string_contains(s: js, pattern: "sj.observe_refused") + && string_contains(s: js, pattern: "session · live") + && string_contains(s: js, pattern: "sb.title = sess.session_name") + && string_contains(s: js, pattern: "'observe-refused'") + && string_contains(s: js, pattern: "session observation refused: ") + && string_contains(s: js, pattern: "mn.prepend(bn)") + && string_contains(s: js, pattern: ".catch(") +} + +test fn sessions_banner_is_styled_loud() -> Bool { + return string_contains(s: roadmap_css(), pattern: ".observe-refused { font-size: var(--text-12); color: var(--text); border-left: var(--border-2) solid var(--band-loud); padding-left: var(--space-6); margin: var(--space-4) var(--space-0) var(--space-8); font-family: var(--font-mono) }") +}