tickmarkr 2.5.6 → 2.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +5 -5
  3. package/dist/cli/commands/status.js +95 -34
  4. package/dist/cli/commands/verify.js +6 -4
  5. package/dist/gates/baseline.d.ts +8 -3
  6. package/dist/gates/baseline.js +6 -3
  7. package/dist/gates/review.js +12 -1
  8. package/dist/gates/run-gates.d.ts +4 -1
  9. package/dist/gates/run-gates.js +65 -9
  10. package/dist/gates/test-manifest.d.ts +3 -0
  11. package/dist/gates/test-manifest.js +20 -2
  12. package/dist/gates/test-reporter.js +6 -1
  13. package/dist/graph/graph.d.ts +2 -0
  14. package/dist/graph/graph.js +5 -0
  15. package/dist/run/activity.d.ts +28 -0
  16. package/dist/run/activity.js +194 -0
  17. package/dist/run/daemon.d.ts +9 -0
  18. package/dist/run/daemon.js +200 -46
  19. package/dist/run/git.d.ts +6 -1
  20. package/dist/run/git.js +63 -11
  21. package/dist/run/journal.d.ts +20 -4
  22. package/dist/run/journal.js +60 -9
  23. package/dist/run/operator-page-summary.d.ts +56 -0
  24. package/dist/run/operator-page-summary.js +68 -0
  25. package/dist/run/operator-summary.d.ts +69 -0
  26. package/dist/run/operator-summary.js +77 -0
  27. package/dist/run/protocol.d.ts +71 -0
  28. package/dist/run/protocol.js +32 -0
  29. package/dist/tui/cockpit/board.d.ts +9 -0
  30. package/dist/tui/cockpit/board.js +10 -0
  31. package/dist/tui/cockpit/derive.d.ts +35 -0
  32. package/dist/tui/cockpit/derive.js +152 -10
  33. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  34. package/dist/tui/cockpit/evidence-view.js +42 -12
  35. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  36. package/dist/tui/cockpit/run-cockpit.js +95 -1
  37. package/dist/tui/cockpit/run-view.d.ts +37 -0
  38. package/dist/tui/cockpit/run-view.js +189 -2
  39. package/package.json +1 -1
  40. package/skills/tickmarkr-overseer/SKILL.md +55 -3
@@ -51,6 +51,79 @@ export async function closeLiveSlot(liveSlots, driver, slot) {
51
51
  throw error;
52
52
  }
53
53
  }
54
+ // A transport timeout says nothing about whether a mutation reached the terminal.
55
+ class HeldProbeExhausted extends Error {
56
+ }
57
+ function isTransportTimeout(error) {
58
+ if (!(error instanceof Error))
59
+ return false;
60
+ const e = error;
61
+ return e.timedOut === true || ["ETIMEDOUT", "transport_timeout", "timeout"].includes(e.code ?? "")
62
+ || /\b(?:timed? out|timeout)\b/i.test(e.reason ?? e.message);
63
+ }
64
+ export function heldWorkerTransport(driver, slot, dispatchId, held, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
65
+ const delays = [250, 500, 1_000];
66
+ const probe = async (operation, call) => {
67
+ for (let retry = 0;; retry++) {
68
+ try {
69
+ return await call();
70
+ }
71
+ catch (error) {
72
+ if (!isTransportTimeout(error))
73
+ throw error;
74
+ held({ operation, retry, state: "held", error: String(error), backoffMs: delays[retry] });
75
+ if (retry === delays.length)
76
+ throw new HeldProbeExhausted(`${operation} transport retry budget exhausted`);
77
+ await sleep(delays[retry]);
78
+ }
79
+ }
80
+ };
81
+ return {
82
+ read: (s, n) => probe("read", () => driver.read(s, n)),
83
+ status: (s) => probe("status", () => driver.status(s)),
84
+ waitOutput: (s, p, ms, o) => probe("waitOutput", () => driver.waitOutput(s, p, ms, o)),
85
+ waitAgentStatus: (s, st, ms) => probe("waitAgentStatus", () => driver.waitAgentStatus(s, st, ms)),
86
+ run: async (s, command) => {
87
+ const observer = driver;
88
+ // A second delivery needs a receipt for THIS mutation, not an empty or idle terminal.
89
+ for (let delivery = 0; delivery < 2; delivery++) {
90
+ try {
91
+ await driver.run(s, command);
92
+ return;
93
+ }
94
+ catch (error) {
95
+ if (!isTransportTimeout(error))
96
+ throw error;
97
+ let notAccepted = false;
98
+ for (let retry = 0; retry <= delays.length; retry++) {
99
+ held({ operation: "run", retry, delivery, state: "held", dispatchId, error: String(error), backoffMs: delays[retry] });
100
+ try {
101
+ const receipt = await observer.observeDispatch?.(slot, command, dispatchId);
102
+ if (receipt?.authoritative === true && receipt.slotId === slot.id
103
+ && receipt.command === command && receipt.dispatchId === dispatchId) {
104
+ if (receipt.outcome === "accepted")
105
+ return;
106
+ if (receipt.outcome === "not-accepted") {
107
+ notAccepted = true;
108
+ break;
109
+ }
110
+ }
111
+ const text = await driver.read(slot, PANE_READ_ROWS);
112
+ if (text.split(/\r?\n/).some((line) => line === `TICKMARKR_DISPATCH_${dispatchId}`))
113
+ return;
114
+ }
115
+ catch { /* failed observations prove neither acceptance nor nonacceptance */ }
116
+ if (retry < delays.length)
117
+ await sleep(delays[retry]);
118
+ }
119
+ if (!notAccepted)
120
+ break;
121
+ }
122
+ }
123
+ throw new HeldProbeExhausted("dispatch outcome remained uncertain; no delivery authorized");
124
+ },
125
+ };
126
+ }
54
127
  // A fixed attempt ceiling is independent of the rolling inactivity clock.
55
128
  let attemptHardTimeoutMs;
56
129
  export function setAttemptHardTimeoutMsForTests(ms) { attemptHardTimeoutMs = ms; }
@@ -474,7 +547,9 @@ async function captureWorkerStream(journalDir, taskId, attempt, driver, slot, fa
474
547
  try {
475
548
  captured = await driver.read(slot, PANE_READ_ROWS);
476
549
  }
477
- catch {
550
+ catch (error) {
551
+ if (error instanceof HeldProbeExhausted)
552
+ throw error;
478
553
  // Earlier successful reads are still a captured stream. Persist them rather than losing the
479
554
  // only failure evidence because the pane vanished between its terminal read and this snapshot.
480
555
  }
@@ -1907,7 +1982,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1907
1982
  // The SAME comparator status uses (engagementComparable); one decision, two consumers. Fail closed:
1908
1983
  // no resume path silently accepts a mismatched or unbound journal. --graph-changed is the operator's
1909
1984
  // audited release for the stop-amend-resume workflow, journaling a graph-rehash naming both hashes.
1910
- graph = applyScopeAmendments(graph, journal, true);
1985
+ graph = applyScopeAmendments(graph, journal, true, opts.graphChanged === true);
1911
1986
  const loadedHash = graphDefinitionHash(graph);
1912
1987
  const cmp = engagementComparable(journal.read(), loadedHash);
1913
1988
  if (!cmp.comparable) {
@@ -1944,6 +2019,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1944
2019
  pid: process.pid, // v1.13 (VIS-11): record the live daemon pid for status liveness
1945
2020
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
1946
2021
  ...(opts.retryFailed ? { retryFailed: true } : {}),
2022
+ ...(opts.graphChanged ? { graphChanged: true } : {}), // OBS-1073: the release is the engagement's
1947
2023
  });
1948
2024
  await placeBoard();
1949
2025
  }
@@ -2083,15 +2159,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2083
2159
  ];
2084
2160
  return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
2085
2161
  };
2086
- // `present`: OBS-1007 add.4/5 — a gate DIAGNOSTIC may name only paths that exist in the tree or the
2087
- // diff (`f.filepath`, `scripted.sh`, `gates.diffCap` lifted from review prose are not paths). A
2088
- // worker's own scope refusal keeps the lexical rule: the file it needs may not exist yet.
2089
- const namedPaths = (text, present) => [...text.matchAll(/(?:^|[\s`'"(])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
2162
+ // Lexing never starts in the middle of a word. Resolution is separate so missing hints
2163
+ // remain visible evidence without becoming executable approval advice.
2164
+ const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
2090
2165
  .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
2091
- .filter((path) => !path.split("/").includes("..")
2092
- && (present
2093
- ? present.has(path)
2094
- : (/\.[A-Za-z][A-Za-z0-9_-]*$/.test(basename(path)) || existsSync(join(repoRoot, path)))));
2166
+ .filter((path) => !path.split("/").includes(".."));
2095
2167
  const diffPaths = async (base, wt) => {
2096
2168
  const out = await shGit(`git diff --name-only -z ${shq(base)}..HEAD`, wt);
2097
2169
  return new Set(out.code === 0 ? out.stdout.split("\0").filter(Boolean) : []);
@@ -2105,9 +2177,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2105
2177
  // OBS-979: a worker refusal can identify the missing authoring scope even when gates
2106
2178
  // subsequently supply the park's disposition. Keep that actionable path on the park itself.
2107
2179
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
2108
- if (t.files.length > 0 && worker?.data.ok === false && typeof worker.data.summary === "string") {
2180
+ if (!details.approveCommand && t.files.length > 0 && worker?.data.ok === false && typeof worker.data.summary === "string") {
2109
2181
  const allowed = filesGlob(t.files);
2110
- const paths = namedPaths(worker.data.summary).filter((path) => !allowed(path));
2182
+ const paths = namedPaths(worker.data.summary).filter((path) => !allowed(path) && existsSync(join(repoRoot, path)));
2111
2183
  if (paths.length)
2112
2184
  reason += ` — files[] repair hint: ${[...new Set(paths)].join(", ")}`;
2113
2185
  }
@@ -2143,14 +2215,20 @@ export async function runDaemon(repoRoot, opts = {}) {
2143
2215
  if (!verdict && t.files.length > 0) {
2144
2216
  const allowed = filesGlob(t.files);
2145
2217
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
2146
- const refusal = worker?.data.ok === false && typeof worker.data.summary === "string"
2218
+ const requested = worker?.data.ok === false && typeof worker.data.summary === "string"
2147
2219
  && /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i.test(worker.data.summary)
2148
2220
  ? namedPaths(worker.data.summary).filter((path) => !allowed(path)) : [];
2149
2221
  const reds = results.filter(gateFailed);
2150
2222
  // A scope verdict already attributes actual out-of-scope EDITS using the collateral map.
2151
2223
  // Path-only diagnostics from the other gates name code that needs fixing, not an edit the
2152
2224
  // worker made. Do not replace the scope gate's stronger attribution with that inference.
2153
- const paths = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details, changed));
2225
+ const hints = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details));
2226
+ const unresolved = [...new Set([...requested, ...hints].filter((path) => !changed.has(path)))];
2227
+ if (unresolved.length)
2228
+ journal.append("scope-hint-unresolved", t.id, { paths: unresolved,
2229
+ reason: `scope hint resolves to nothing in the task tree: ${unresolved.join(", ")}`, attempt: attempts + 1 });
2230
+ const refusal = requested.filter((path) => changed.has(path));
2231
+ const paths = hints.filter((path) => changed.has(path));
2154
2232
  if (refusal.length || (paths.length > 0 && paths.every((path) => !allowed(path)))) {
2155
2233
  const classification = {
2156
2234
  gate: refusal.length ? "worker" : reds[0]?.gate,
@@ -2158,14 +2236,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2158
2236
  repair: `files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}`,
2159
2237
  attempt: attempts + 1, chargeable: false, source: "diagnostic",
2160
2238
  };
2161
- if (refusal.length)
2239
+ const scopeRequest = refusal.length > 0 || reds.some((g) => g.gate === "review");
2240
+ if (scopeRequest)
2162
2241
  journal.append("scope-request", t.id, classification);
2163
2242
  else
2164
2243
  journal.append("scope-authoring", t.id, classification);
2165
2244
  // OBS-547 (Leg-2 T3 material): unchargeable ⇒ metered 0, exactly as the predicted path below —
2166
2245
  // passing the physical count would write `meteredAttempts: 1` beside `attempts: 0`.
2167
- const approveCommand = `tickmarkr approve ${runId} ${t.id} --files ${[...new Set(refusal)].map((path) => /^[\w./-]+$/.test(path) ? path : shq(path)).join(",")}`;
2168
- await park(t, `${refusal.length ? "scope request" : "authoring defect"} — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}`, refusal.length ? "scope-request" : "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode, refusal.length ? { paths: [...new Set(refusal)], approveCommand, graphDefinitionHash: graphDefinitionHash(graph) } : {});
2246
+ const approveCommand = `tickmarkr approve ${runId} ${t.id} --files ${[...new Set(refusal.length ? refusal : paths)].map((path) => /^[\w./-]+$/.test(path) ? path : shq(path)).join(",")}`;
2247
+ await park(t, `${scopeRequest ? "scope request" : "authoring defect"} — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}${scopeRequest ? `\n${approveCommand}` : ""}`, scopeRequest ? "scope-request" : "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode, scopeRequest ? { paths: [...new Set(refusal.length ? refusal : paths)], approveCommand, graphDefinitionHash: graphDefinitionHash(graph) } : {});
2169
2248
  return true;
2170
2249
  }
2171
2250
  }
@@ -2205,7 +2284,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2205
2284
  approvalSweepCursor = events.length;
2206
2285
  if (approvals.length === 0)
2207
2286
  return;
2208
- graph = applyScopeAmendments(graph, journal);
2287
+ graph = applyScopeAmendments(graph, journal, false, opts.graphChanged === true); // OBS-1073: same release as launch
2209
2288
  resume = journal.replayResumeState();
2210
2289
  satisfiedGates = journal.replaySatisfiedGates();
2211
2290
  replayedGateResults = resumeLifecycleOpen
@@ -2363,6 +2442,13 @@ export async function runDaemon(repoRoot, opts = {}) {
2363
2442
  ctx.requiredRepairTests = decision.requiredFiles;
2364
2443
  ctx.selectionReason = decision.reason;
2365
2444
  }
2445
+ // Resume and ordinary verification share this boundary. Count persisted rounds so a
2446
+ // resumed daemon cannot reuse a previous round's identity within the same attempt.
2447
+ ctx.buildReceiptIdentity = {
2448
+ runId, taskId: task.id, attempt: gateSubject?.attempt ?? 0,
2449
+ gateRound: journal.read().filter((row) => row.taskId === task.id
2450
+ && row.event === "phase-start" && row.data.phase === "gates").length,
2451
+ };
2366
2452
  const round = await runGates(task, ctx);
2367
2453
  let review = round.results.find((g) => g.gate === "review");
2368
2454
  while (review?.meta?.noVerdict === true) {
@@ -2895,9 +2981,37 @@ export async function runDaemon(repoRoot, opts = {}) {
2895
2981
  else
2896
2982
  reused.push(gate);
2897
2983
  }
2984
+ // OBS-1049: build outputs are not commits. A replayed build verdict says the tree WAS green;
2985
+ // it says nothing about whether the RECREATED checkout holds what that build produced (dist),
2986
+ // and the host's npm lifecycle (ignore-scripts) may never rebuild it before the suite runs.
2987
+ // So a replayed build re-runs its own command here as provisioning, journaled by name; a red
2988
+ // provisioning is not reused — build (and everything behind it) re-enters the battery as a gate.
2989
+ // Every resume on this path recreates the checkout (recreateTaskWorktree above), so "reused
2990
+ // build" is exactly "build replayed onto a recreated tree".
2991
+ // Journal order is the criterion's order: a GREEN provisioning is recorded AFTER the reuse
2992
+ // rows it belongs to (gate-reused build, then gate-provisioned build); a RED provisioning is
2993
+ // recorded alone, and no reuse row follows it.
2994
+ let provisionedRow;
2995
+ if (reused.includes("build") && commands.build !== undefined) {
2996
+ // Provisioning is a verification command like any gate: it waits for the run's baseline and
2997
+ // takes the command lease (suite admission), never a bare shell beside sibling gates.
2998
+ await waitForBaseline(t.id);
2999
+ const startedAt = Date.now();
3000
+ const provisioned = await withCommandContext(t.id, () => sh(commands.build, wt));
3001
+ provisionedRow = {
3002
+ gate: "build", commit: replayedGates.commit, exitCode: provisioned.code, durationMs: Date.now() - startedAt,
3003
+ };
3004
+ if (provisioned.code !== 0) {
3005
+ reused.splice(reused.indexOf("build"));
3006
+ journal.append("gate-provisioned", t.id, provisionedRow);
3007
+ provisionedRow = undefined;
3008
+ }
3009
+ }
2898
3010
  for (const gate of reused) {
2899
3011
  journal.append("gate-reused", t.id, { gate, commit: replayedGates.commit });
2900
3012
  }
3013
+ if (provisionedRow !== undefined)
3014
+ journal.append("gate-provisioned", t.id, provisionedRow);
2901
3015
  remainingGates = declaredGates.slice(reused.length);
2902
3016
  }
2903
3017
  const resumedTask = { ...t, gates: remainingGates };
@@ -2935,6 +3049,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2935
3049
  // Leg-2 (OBS-1052): the run-scoped two-strike tally — without it retirement is inert and a
2936
3050
  // flaking seat is re-asked on every task. (carriedAuthors is held back: see SURGEON-LEG2-REPORT.)
2937
3051
  reviewNoVerdicts,
3052
+ recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
2938
3053
  onGate: async (e) => {
2939
3054
  if (e.phase === "start") {
2940
3055
  notePhaseStart(e);
@@ -2997,8 +3112,21 @@ export async function runDaemon(repoRoot, opts = {}) {
2997
3112
  // Observed green gates are only measurements. If the resumed suffix is red, preserve that
2998
3113
  // result in the journal and return to the ordinary attempt/consult ladder, which rebuilds
2999
3114
  // feedback from those rows. Only an operator-authorized gate release parks on a new red.
3000
- if (!satisfiedGate)
3115
+ if (!satisfiedGate) {
3116
+ // OBS-1055: a recheck red funds a repair of the pin's own work. A pinned task's repair sits
3117
+ // on the pin (OBS-1034's exemption keeps the tried list from excluding it); a pin the fleet
3118
+ // cannot seat parks naming the pin — never the ladder.
3119
+ const pin = recheck ? t.routingHints?.pin : undefined;
3120
+ if (pin) {
3121
+ const seat = channels.find((c) => c.adapter === pin.via && c.model === pin.model && !demotedChannels.has(channelKey(c)));
3122
+ if (!seat) {
3123
+ await park(t, `recheck red: pinned ${pin.via}:${pin.model} is unavailable to host the repair — refusing the ladder`, "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
3124
+ return;
3125
+ }
3126
+ assignment = { adapter: seat.adapter, model: seat.model, channel: seat.channel, tier: seat.tier };
3127
+ }
3001
3128
  break gateLoop;
3129
+ }
3002
3130
  await park(t, gateFailApprovalReason(t.id, "post-approval gate failed", results.some((g) => g.gate === "review" && gateFailed(g))), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
3003
3131
  return;
3004
3132
  }
@@ -3396,9 +3524,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3396
3524
  `if [ -n "$worker_pgid" ] && [ -n "$daemon_pgid" ] && [ "$worker_pgid" != "$daemon_pgid" ]; then printf '%s\\n' "$worker_pgid" > ${shq(groupFile)}; fi`,
3397
3525
  "export BASH_SILENCE_DEPRECATION_WARNING=1",
3398
3526
  bannerShell(),
3527
+ `printf '%s\\n' 'TICKMARKR_DISPATCH_${nonce}'`,
3399
3528
  workerCmd,
3400
3529
  exitMarkerCmd,
3401
3530
  ].join("\n"));
3531
+ const workerTransport = heldWorkerTransport(driver, slot, nonce, (data) => journal.append("held-probe", t.id, { ...data, slot: slot.id, attempt }));
3402
3532
  // T2 (OBS-264): the liveness triad, shared by BOTH wait loops (a headless worker stalls on
3403
3533
  // finished work exactly as a visible one does — and rode the whole window before this). It
3404
3534
  // sits ABOVE the fast-kill and the nudge because the population it governs is the opposite
@@ -3559,6 +3689,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3559
3689
  let driverProbeFailed = false;
3560
3690
  let heldLegs = [];
3561
3691
  const noteDriverUnreadable = (error) => {
3692
+ if (error instanceof HeldProbeExhausted)
3693
+ throw error;
3562
3694
  driverProbeFailed = true;
3563
3695
  heldLegs = ["quota:driver-unreadable", "stall:driver-unreadable"];
3564
3696
  journal.append("contact-unreadable", t.id, { slot: slot.name, attempt, source: "driver", concludes: false,
@@ -3657,8 +3789,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3657
3789
  try {
3658
3790
  seedResult = await runInteractiveSeed({
3659
3791
  driver: trustAnswered ? {
3660
- run: driver.run.bind(driver), waitOutput: driver.waitOutput.bind(driver), read: driver.read.bind(driver),
3661
- } : driver,
3792
+ run: workerTransport.run.bind(workerTransport), waitOutput: workerTransport.waitOutput.bind(workerTransport), read: workerTransport.read.bind(workerTransport),
3793
+ } : { ...trackedDriver, ...workerTransport },
3662
3794
  slot, adapter, assignment, promptFile, taskTimeoutMinutes,
3663
3795
  onTrustAnswered: noteSeedTrustAnswered,
3664
3796
  });
@@ -3675,7 +3807,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3675
3807
  }
3676
3808
  else {
3677
3809
  try {
3678
- await driver.run(slot, paneDispatchCommand(dispatchScript));
3810
+ await workerTransport.run(slot, paneDispatchCommand(dispatchScript));
3679
3811
  }
3680
3812
  catch (error) {
3681
3813
  if (!(error instanceof DeliveryReadinessError))
@@ -3685,7 +3817,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3685
3817
  return;
3686
3818
  }
3687
3819
  await noteLaunched();
3688
- output = await driver.read(slot, PANE_READ_ROWS);
3820
+ output = await workerTransport.read(slot, PANE_READ_ROWS);
3689
3821
  }
3690
3822
  // The returning paths report the same fact on the result; both callbacks land on the one
3691
3823
  // latch, and the second is a no-op. A seed that answered is never re-answered by the loop.
@@ -3758,11 +3890,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3758
3890
  // every slice before the gate exactly as long as it already was.
3759
3891
  slice = Math.min(slice, harvestSliceMs(sliceStart - lastProgressAt));
3760
3892
  slice = Math.min(slice, remainingExecutionMs() ?? slice);
3761
- if (await driver.waitOutput(slot, `(${trailerPattern(nonce)})|TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true }).catch((error) => { noteDriverUnreadable(error); return false; })) {
3893
+ if (await workerTransport.waitOutput(slot, `(${trailerPattern(nonce)})|TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true }).catch((error) => { noteDriverUnreadable(error); return false; })) {
3762
3894
  // verify before accepting: a worker that merely DISPLAYS a marker (e.g. editing tickmarkr's
3763
3895
  // own source, where "TICKMARKR_EXIT:" is a string literal) must not end the wait. Only a
3764
3896
  // parseable trailer or a digit-suffixed exit marker in the harvest is completion.
3765
- output = await driver.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return output; }); // TUI transcripts carry chrome — read deeper than print's 500
3897
+ output = await workerTransport.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return output; }); // TUI transcripts carry chrome — read deeper than print's 500
3766
3898
  finished = sampleTrailer(output);
3767
3899
  const exit = exitRe.exec(output);
3768
3900
  if (finished || (exit && !(trailerFrames && new RegExp(trailerPattern(nonce)).test(output)))) {
@@ -3786,7 +3918,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3786
3918
  // rolling taskTimeoutMinutes window as the backstop and name the held probe once.
3787
3919
  let paneText;
3788
3920
  try {
3789
- paneText = await driver.read(slot, PANE_READ_ROWS);
3921
+ paneText = await workerTransport.read(slot, PANE_READ_ROWS);
3790
3922
  }
3791
3923
  catch (error) {
3792
3924
  noteDriverUnreadable(error);
@@ -3903,7 +4035,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3903
4035
  // "unknown"/"working" never page.
3904
4036
  let st;
3905
4037
  try {
3906
- st = await driver.status(slot);
4038
+ st = await workerTransport.status(slot);
3907
4039
  }
3908
4040
  catch (error) {
3909
4041
  noteDriverUnreadable(error);
@@ -3929,7 +4061,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3929
4061
  // text matches. tickmarkr created the worktree from the operator's own repo — safe by construction.
3930
4062
  if (!trustAnswered && adapter.trustDialog && driver.sendKey) {
3931
4063
  try {
3932
- const paneText = await driver.read(slot, 80);
4064
+ const paneText = await workerTransport.read(slot, 80);
3933
4065
  if (matchesTrustDialog(paneText, adapter.trustDialog)) {
3934
4066
  trustAnswered = true;
3935
4067
  trustAnsweredSlots.add(slot.id);
@@ -3943,7 +4075,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3943
4075
  continue; // do not page — keep waiting for the trailer
3944
4076
  }
3945
4077
  }
3946
- catch {
4078
+ catch (error) {
4079
+ if (error instanceof HeldProbeExhausted)
4080
+ throw error;
3947
4081
  /* read/send failed — fall through to page the operator */
3948
4082
  }
3949
4083
  }
@@ -3975,7 +4109,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3975
4109
  let paneAbsent = paneAbsentCandidate;
3976
4110
  if (paneAbsent) {
3977
4111
  try {
3978
- const confirmation = await driver.read(slot, PANE_READ_ROWS);
4112
+ const confirmation = await workerTransport.read(slot, PANE_READ_ROWS);
3979
4113
  paneAbsent = confirmation.trim().length === 0;
3980
4114
  if (!paneAbsent) {
3981
4115
  everHadOutput = true;
@@ -3984,6 +4118,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3984
4118
  }
3985
4119
  }
3986
4120
  catch (error) {
4121
+ if (error instanceof HeldProbeExhausted)
4122
+ throw error;
3987
4123
  paneAbsent = false;
3988
4124
  if (!paneReadHeld) {
3989
4125
  paneReadHeld = true;
@@ -3999,7 +4135,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3999
4135
  : "unmeasurable";
4000
4136
  if (processTree === "empty") {
4001
4137
  try {
4002
- const confirmation = await driver.read(slot, PANE_READ_ROWS);
4138
+ const confirmation = await workerTransport.read(slot, PANE_READ_ROWS);
4003
4139
  paneAbsent = confirmation.trim().length === 0;
4004
4140
  if (!paneAbsent) {
4005
4141
  everHadOutput = true;
@@ -4008,6 +4144,8 @@ export async function runDaemon(repoRoot, opts = {}) {
4008
4144
  }
4009
4145
  }
4010
4146
  catch (error) {
4147
+ if (error instanceof HeldProbeExhausted)
4148
+ throw error;
4011
4149
  paneAbsent = false;
4012
4150
  if (!paneReadHeld) {
4013
4151
  paneReadHeld = true;
@@ -4167,7 +4305,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4167
4305
  if (delivered) {
4168
4306
  // absorb the nudge's own echo BEFORE arming the grace timer — post-nudge progress
4169
4307
  // is measured against this baseline, not against the echo.
4170
- const echo = await driver.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return undefined; });
4308
+ const echo = await workerTransport.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return undefined; });
4171
4309
  if (echo !== undefined)
4172
4310
  stallProgress.observe({ paneText: echo, contextTokens });
4173
4311
  nudgeDeadline = Date.now() + workerNudgeGraceMs;
@@ -4184,7 +4322,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4184
4322
  // grace spent, still no post-nudge progress: re-harvest once (the trailer may have
4185
4323
  // landed between polls), then conclude the wait as a stall NOW — the consult sees the
4186
4324
  // un-answered nudge instead of the remainder of the window.
4187
- const finalPane = await driver.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return undefined; });
4325
+ const finalPane = await workerTransport.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return undefined; });
4188
4326
  if (finalPane === undefined)
4189
4327
  continue;
4190
4328
  nudgeDeadline = undefined;
@@ -4239,9 +4377,11 @@ export async function runDaemon(repoRoot, opts = {}) {
4239
4377
  if (hardTimedOut)
4240
4378
  journal.append("worker-hard-timeout", t.id, { slot: slot.name, attempt, heldLegs });
4241
4379
  try {
4242
- output = await driver.read(slot, PANE_READ_ROWS);
4380
+ output = await workerTransport.read(slot, PANE_READ_ROWS);
4243
4381
  }
4244
- catch {
4382
+ catch (error) {
4383
+ if (error instanceof HeldProbeExhausted)
4384
+ throw error;
4245
4385
  // The poll loop already recorded the unreadable pane. Retain the last readable bytes
4246
4386
  // so this ambiguous path still reaches the ordinary timeout/consult backstop.
4247
4387
  }
@@ -4251,8 +4391,8 @@ export async function runDaemon(repoRoot, opts = {}) {
4251
4391
  processExited = exit !== null;
4252
4392
  }
4253
4393
  if (finished && !trailerFrames) {
4254
- await driver.waitAgentStatus(slot, "idle", 5_000).catch(noteDriverUnreadable);
4255
- const settled = await driver.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return output; });
4394
+ await workerTransport.waitAgentStatus(slot, "idle", 5_000).catch(noteDriverUnreadable);
4395
+ const settled = await workerTransport.read(slot, PANE_READ_ROWS).catch((error) => { noteDriverUnreadable(error); return output; });
4256
4396
  if (sampleTrailer(settled))
4257
4397
  output = settled;
4258
4398
  }
@@ -4271,7 +4411,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4271
4411
  if (remaining <= 0)
4272
4412
  break;
4273
4413
  await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
4274
- output = await driver.read(slot, PANE_READ_ROWS);
4414
+ output = await workerTransport.read(slot, PANE_READ_ROWS);
4275
4415
  trailerFrames?.sample(output, new RegExp(trailerPattern(nonce)).test(output));
4276
4416
  settleParsed = adapter.parse(output, nonce);
4277
4417
  settleTries++;
@@ -4284,7 +4424,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4284
4424
  }
4285
4425
  else {
4286
4426
  try {
4287
- await driver.run(slot, paneDispatchCommand(dispatchScript));
4427
+ await workerTransport.run(slot, paneDispatchCommand(dispatchScript));
4288
4428
  }
4289
4429
  catch (error) {
4290
4430
  if (!(error instanceof DeliveryReadinessError))
@@ -4297,7 +4437,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4297
4437
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
4298
4438
  // v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
4299
4439
  const stallWindowMs = taskTimeoutMinutes * 60_000;
4300
- const initialPane = await driver.read(slot, 500);
4440
+ const initialPane = await workerTransport.read(slot, 500);
4301
4441
  startupFailure = startupFailureInWindow(initialPane, workerLaunchedAt);
4302
4442
  let everHadOutput = initialPane.length > 0;
4303
4443
  const stallProgress = new StallProgressTracker();
@@ -4328,11 +4468,11 @@ export async function runDaemon(repoRoot, opts = {}) {
4328
4468
  // T2 (OBS-264): same probe cadence the interactive loop uses — see harvestSliceMs.
4329
4469
  slice = Math.min(slice, harvestSliceMs(Date.now() - lastProgressAt));
4330
4470
  slice = Math.min(slice, remainingExecutionMs() ?? slice);
4331
- if (await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true })) {
4471
+ if (await workerTransport.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, slice, { regex: true })) {
4332
4472
  processExited = true;
4333
4473
  break;
4334
4474
  }
4335
- const paneText = await driver.read(slot, 500);
4475
+ const paneText = await workerTransport.read(slot, 500);
4336
4476
  if (paneText.length > 0)
4337
4477
  everHadOutput = true;
4338
4478
  if (startupFailureInWindow(paneText, workerLaunchedAt)) {
@@ -4357,7 +4497,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4357
4497
  if (await harvestConcludes(Date.now() - lastProgressAt))
4358
4498
  break;
4359
4499
  }
4360
- output = await driver.read(slot, 500);
4500
+ output = await workerTransport.read(slot, 500);
4361
4501
  exitCode = Number(exitRe.exec(output)?.[1] ?? 1);
4362
4502
  // Completion is the trailer, exactly as in the interactive loop. A process that exited
4363
4503
  // without one is finished:false with a non-null exitCode — the harvest synthesis then owns
@@ -4375,13 +4515,13 @@ export async function runDaemon(repoRoot, opts = {}) {
4375
4515
  // interactive attempts to the same exit marker before close and the post-hoc usage disk read
4376
4516
  // so a writer never races the reader (real CLIs can flush usage asynchronously after the trailer).
4377
4517
  if (interactive && finished && !exitRe.test(output)) {
4378
- await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, 2_000, { regex: true }).catch(noteDriverUnreadable);
4518
+ await workerTransport.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, 2_000, { regex: true }).catch(noteDriverUnreadable);
4379
4519
  }
4380
4520
  // Free availability reroutes deliberately reuse the charged-attempt ordinal. Stream artifacts
4381
4521
  // cannot: every dispatch owns bytes that must remain independently recoverable, so derive the
4382
4522
  // append-only dispatch ordinal from the journal instead of overwriting an earlier a<n>.out.
4383
4523
  const streamAttempt = journal.read().filter((event) => event.event === "task-dispatch" && event.taskId === t.id).length - 1;
4384
- const capturedStream = await captureWorkerStream(journal.dir, t.id, streamAttempt, driver, slot, output);
4524
+ const capturedStream = await captureWorkerStream(journal.dir, t.id, streamAttempt, { ...trackedDriver, ...workerTransport }, slot, output);
4385
4525
  // An interactive settle already selected the bounded parse it is allowed to use. Every other
4386
4526
  // path parses the common final stream snapshot — notably envelope adapters whose trailer is
4387
4527
  // encoded and therefore invisible to the raw wait regex. Keep `output` bounded: every text
@@ -4466,7 +4606,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4466
4606
  // (provider outage, quota banner, dead CLI) must still route; the harvest synthesis only
4467
4607
  // decides whether THIS attempt's worktree goes to gates, and when routing wins instead, the
4468
4608
  // commits survive via the existing commitsToCarry/cherryPickCommits carry-forward.
4469
- if (workerFinished && !result.ok && await dispositionScopeRed(t, [], assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode))
4609
+ if (workerFinished && !result.ok && await dispositionScopeRed(t, [], assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
4470
4610
  return;
4471
4611
  if (!workerFinished && !processExited && (timedOut || deadChannelKilled || quotaBannerKilled)) {
4472
4612
  const seat = channelKey(assignment);
@@ -5123,6 +5263,20 @@ export async function runDaemon(repoRoot, opts = {}) {
5123
5263
  }
5124
5264
  }
5125
5265
  const cleanupEvidence = cleanupErrors.length ? { cleanupErrors } : {};
5266
+ if (err instanceof HeldProbeExhausted) {
5267
+ const wt = worktreePath(repoRoot, `${branch}--${t.id}`);
5268
+ let ref = await withoutExecutionBudget(() => preserveWorktree(wt));
5269
+ if (!ref) {
5270
+ const head = await gitHead(wt);
5271
+ ref = `refs/tickmarkr/preserved/${head}`;
5272
+ const saved = await shGit(`git update-ref ${shq(ref)} ${shq(head)}`, wt);
5273
+ if (saved.code !== 0)
5274
+ throw new Error(`could not preserve ${head}: ${saved.stderr}`);
5275
+ }
5276
+ journal.append("worktree-preserved", t.id, { ref });
5277
+ await park(t, err.message, "infra", null, 0, Date.now(), 0, 0, undefined, 0, "fresh", { disposition: "transport-uncertain", ref, ...cleanupEvidence });
5278
+ return;
5279
+ }
5126
5280
  if (err instanceof ExecutionBudgetExceeded) {
5127
5281
  const wt = worktreePath(repoRoot, `${branch}--${t.id}`);
5128
5282
  const ref = await preserveWorktree(wt);
package/dist/run/git.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CommandReceiptAttribution, ShellReceipt } from "./protocol.js";
1
2
  import { spawn } from "node:child_process";
2
3
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
3
4
  export { ROUTING_ENV_SEAMS };
@@ -150,10 +151,14 @@ export interface ShellOptions {
150
151
  signal?: AbortSignal;
151
152
  onSpawn?: (pid: number | undefined) => void;
152
153
  onTimeout?: () => void;
154
+ /** Independent of the legacy pid callback; started means the child emitted spawn. */
155
+ onReceipt?: (receipt: ShellReceipt) => void;
156
+ /** Called once per invocation (1-based), including each pre-spawn retry. */
157
+ receiptAttribution?: (invocation: number) => CommandReceiptAttribution;
153
158
  }
154
159
  /** Shared command seam, including invocation-bound manifested runners. */
155
160
  export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
156
- export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
161
+ export declare function sh(cmd: string, cwd: string, timeoutMs?: number, options?: ShellOptions): Promise<ShResult>;
157
162
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
158
163
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
159
164
  export declare function shGitOk(cmd: string, cwd: string): Promise<string>;