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.
- package/dist/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +5 -5
- package/dist/cli/commands/status.js +95 -34
- package/dist/cli/commands/verify.js +6 -4
- package/dist/gates/baseline.d.ts +8 -3
- package/dist/gates/baseline.js +6 -3
- package/dist/gates/review.js +12 -1
- package/dist/gates/run-gates.d.ts +4 -1
- package/dist/gates/run-gates.js +65 -9
- package/dist/gates/test-manifest.d.ts +3 -0
- package/dist/gates/test-manifest.js +20 -2
- package/dist/gates/test-reporter.js +6 -1
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +5 -0
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/daemon.d.ts +9 -0
- package/dist/run/daemon.js +200 -46
- package/dist/run/git.d.ts +6 -1
- package/dist/run/git.js +63 -11
- package/dist/run/journal.d.ts +20 -4
- package/dist/run/journal.js +60 -9
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- package/dist/tui/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +55 -3
package/dist/run/daemon.js
CHANGED
|
@@ -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
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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, `${
|
|
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:
|
|
3661
|
-
} :
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4255
|
-
const settled = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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>;
|