tickmarkr 2.6.1 → 2.6.3
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/README.md +16 -3
- package/dist/adapters/catalog-remote.js +89 -47
- package/dist/adapters/claude-code.js +9 -6
- package/dist/adapters/codex.js +7 -4
- package/dist/adapters/prompt.d.ts +1 -0
- package/dist/adapters/prompt.js +14 -6
- package/dist/adapters/registry.js +3 -3
- package/dist/adapters/types.d.ts +12 -4
- package/dist/adapters/types.js +6 -0
- package/dist/cli/commands/approve.d.ts +11 -4
- package/dist/cli/commands/approve.js +82 -27
- package/dist/cli/commands/compile.js +13 -3
- package/dist/cli/commands/doctor.d.ts +8 -2
- package/dist/cli/commands/doctor.js +11 -3
- package/dist/cli/commands/fleet.js +87 -11
- package/dist/cli/commands/plan.js +13 -8
- package/dist/cli/commands/report.d.ts +2 -1
- package/dist/cli/commands/report.js +74 -8
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/status.js +43 -20
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +9 -2
- package/dist/compile/native.js +7 -0
- package/dist/config/config.d.ts +35 -2
- package/dist/config/config.js +86 -10
- package/dist/config/fleet-overlay.d.ts +13 -2
- package/dist/config/fleet-overlay.js +60 -0
- package/dist/drivers/herdr.d.ts +12 -0
- package/dist/drivers/herdr.js +51 -0
- package/dist/drivers/orca.d.ts +35 -2
- package/dist/drivers/orca.js +222 -67
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +2 -2
- package/dist/eval/canary.d.ts +2 -1
- package/dist/eval/canary.js +2 -2
- package/dist/eval/dispatch.js +1 -0
- package/dist/gates/acceptance.d.ts +9 -1
- package/dist/gates/acceptance.js +31 -4
- package/dist/gates/baseline.d.ts +32 -2
- package/dist/gates/baseline.js +111 -24
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +12 -2
- package/dist/gates/llm.d.ts +11 -4
- package/dist/gates/llm.js +40 -21
- package/dist/gates/review.d.ts +14 -1
- package/dist/gates/review.js +160 -34
- package/dist/gates/run-gates.d.ts +56 -4
- package/dist/gates/run-gates.js +358 -58
- package/dist/gates/test-manifest.d.ts +45 -1
- package/dist/gates/test-manifest.js +78 -12
- package/dist/graph/schema.d.ts +2 -0
- package/dist/graph/schema.js +2 -0
- package/dist/plan/scope.js +2 -2
- package/dist/route/preference.d.ts +20 -2
- package/dist/route/preference.js +48 -13
- package/dist/route/router.d.ts +12 -1
- package/dist/route/router.js +56 -24
- package/dist/run/consult.d.ts +15 -1
- package/dist/run/consult.js +18 -7
- package/dist/run/daemon.d.ts +38 -2
- package/dist/run/daemon.js +895 -192
- package/dist/run/git.d.ts +8 -0
- package/dist/run/git.js +14 -0
- package/dist/run/interactive-seed.d.ts +4 -0
- package/dist/run/interactive-seed.js +35 -9
- package/dist/run/journal.d.ts +152 -3
- package/dist/run/journal.js +551 -50
- package/dist/run/lease.d.ts +13 -0
- package/dist/run/lease.js +45 -0
- package/dist/run/merge.d.ts +3 -1
- package/dist/run/merge.js +3 -2
- package/dist/run/operator-summary.d.ts +3 -0
- package/dist/run/operator-summary.js +3 -1
- package/dist/run/protocol.d.ts +46 -1
- package/dist/run/protocol.js +14 -2
- package/dist/run/receipt-resolver.d.ts +22 -0
- package/dist/run/receipt-resolver.js +40 -1
- package/dist/run/repair-selection.d.ts +11 -1
- package/dist/run/repair-selection.js +17 -9
- package/dist/run/supervision.d.ts +7 -1
- package/dist/run/supervision.js +5 -2
- package/dist/run/wall-budget.d.ts +48 -0
- package/dist/run/wall-budget.js +280 -0
- package/dist/tui/cockpit/board.js +3 -3
- package/dist/tui/cockpit/decision-actions.d.ts +8 -5
- package/dist/tui/cockpit/decision-actions.js +55 -32
- package/dist/tui/cockpit/derive.js +13 -2
- package/dist/tui/cockpit/live-runtime.d.ts +10 -0
- package/dist/tui/cockpit/live-runtime.js +50 -3
- package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
- package/dist/tui/cockpit/run-cockpit.js +27 -2
- package/dist/tui/cockpit/run-view.d.ts +9 -2
- package/dist/tui/cockpit/run-view.js +66 -9
- package/dist/tui/cockpit/setup-cockpit.d.ts +6 -0
- package/dist/tui/cockpit/setup-cockpit.js +10 -3
- package/dist/tui/ink/fleet-app.d.ts +15 -3
- package/dist/tui/ink/fleet-app.js +91 -22
- package/package.json +3 -1
- package/schema/config.schema.json +825 -0
- package/skills/tickmarkr-loop/SKILL.md +15 -3
- package/skills/tickmarkr-overseer/SKILL.md +42 -0
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +91 -0
- package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
package/dist/run/journal.js
CHANGED
|
@@ -78,6 +78,171 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
|
|
|
78
78
|
// marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
|
|
79
79
|
// and tried seats intact.
|
|
80
80
|
export const RECHECK_RELEASE = "recheck";
|
|
81
|
+
// OBS-1178: the daemon's answer to a pending approval that no longer binds to the park it names. It
|
|
82
|
+
// enacts nothing and consumes the decision, so every fold reads the task as still parked.
|
|
83
|
+
export const APPROVAL_REFUSED = "approval-refused";
|
|
84
|
+
/** The token every surface prints and `approve --park` accepts: `<line>@<ts>`. */
|
|
85
|
+
export const bindingToken = (binding) => `${binding.line}@${binding.ts}`;
|
|
86
|
+
export function parseBindingToken(token) {
|
|
87
|
+
const match = /^([1-9]\d*)@(\S+)$/u.exec(token);
|
|
88
|
+
return match && Number.isSafeInteger(Number(match[1])) ? { line: Number(match[1]), ts: match[2] } : undefined;
|
|
89
|
+
}
|
|
90
|
+
/** The binding a task-approved row recorded under `park` or `failure`, when well formed. */
|
|
91
|
+
export function recordedBinding(value) {
|
|
92
|
+
if (!value || typeof value !== "object")
|
|
93
|
+
return undefined;
|
|
94
|
+
const { line, ts } = value;
|
|
95
|
+
return typeof line === "number" && Number.isSafeInteger(line) && line > 0 && typeof ts === "string" ? { line, ts } : undefined;
|
|
96
|
+
}
|
|
97
|
+
/** The newest failed gate before a park row — the gate a waive of that park would satisfy. */
|
|
98
|
+
export function failedGateBeforePark(events, taskId, parkIndex) {
|
|
99
|
+
for (let i = parkIndex - 1; i >= 0; i -= 1) {
|
|
100
|
+
const event = events[i];
|
|
101
|
+
if (event.taskId !== taskId)
|
|
102
|
+
continue;
|
|
103
|
+
if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
|
|
104
|
+
return undefined;
|
|
105
|
+
if (event.event === "gate-result" && event.data.pass === false
|
|
106
|
+
&& typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
|
|
107
|
+
return event.data.gate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
// OBS-1178: every row read from a journal file remembers its PHYSICAL 1-based line, so a decision
|
|
113
|
+
// fold keys bindings and refusals by the line an operator's token names — never by a compacted index
|
|
114
|
+
// a blank or torn line would shift. Rows built in memory (tests, fixtures) fall back to index + 1.
|
|
115
|
+
const SOURCE_LINE = new WeakMap();
|
|
116
|
+
/** The physical journal line of `events[i]` (see SOURCE_LINE). */
|
|
117
|
+
export const physicalLine = (events, i) => SOURCE_LINE.get(events[i]) ?? i + 1;
|
|
118
|
+
/** A row parsed outside this module (a cockpit capture) names its own physical line to the decision fold. */
|
|
119
|
+
export const withPhysicalLine = (row, line) => { SOURCE_LINE.set(row, line); return row; };
|
|
120
|
+
// Rows that enact (consume) the decisions open for their task: a dispatch, a battery, a recreation,
|
|
121
|
+
// or the task closing done. A new task-human or task-failed row is NOT one — it proves nothing was
|
|
122
|
+
// enacted, so an earlier decision stays open and is judged against the newer park or failure.
|
|
123
|
+
const ENACTS_DECISIONS = new Set([
|
|
124
|
+
"task-dispatch", "repair-dispatch", "resume-restore", "worker-launch", "recheck-battery", "worktree-recreation",
|
|
125
|
+
"task-done",
|
|
126
|
+
]);
|
|
127
|
+
/**
|
|
128
|
+
* OBS-1178: THE decision fold. Every reader of task-approved rows — the replay folds, the daemon's
|
|
129
|
+
* startup, sweep and pending-action folds, scope-amendment replay and its audits, status and both
|
|
130
|
+
* cockpits — reads decisions through it, so no two surfaces can disagree on whether one happened.
|
|
131
|
+
*
|
|
132
|
+
* `effective` holds the PHYSICAL lines of the task-approved rows whose effects may apply: a row that
|
|
133
|
+
* was sound when an enactment row consumed it (bound by line and timestamp to the task's newest park,
|
|
134
|
+
* a waive also to that park's failed gate, or a recheck to the newest failure with no park after it;
|
|
135
|
+
* a legacy row carrying no binding only when no park or failure landed between it and that
|
|
136
|
+
* enactment), plus a row still open that is sound NOW. A refused row is never effective, and neither is an open row that no
|
|
137
|
+
* longer binds — a newer park or failure landed, or it names none — whatever unrelated rows landed
|
|
138
|
+
* around it. An enactment row also consumes the task's park and failure: a decision naming either
|
|
139
|
+
* after the task moved on is stale, while the decisions that enactment already consumed stay effective.
|
|
140
|
+
* `open` lists every unenacted, unrefused row with why it may not be enacted, and `refused` the lines
|
|
141
|
+
* an approval-refused row answered.
|
|
142
|
+
*/
|
|
143
|
+
export function foldDecisions(events) {
|
|
144
|
+
const lineOf = (i) => physicalLine(events, i);
|
|
145
|
+
const token = (i) => i === undefined ? "none" : bindingToken({ line: lineOf(i), ts: String(events[i].ts) });
|
|
146
|
+
const names = (binding, i) => i !== undefined && lineOf(i) === binding.line && events[i].ts === binding.ts;
|
|
147
|
+
const parks = new Map();
|
|
148
|
+
const failures = new Map();
|
|
149
|
+
const pending = new Map();
|
|
150
|
+
const effective = new Set();
|
|
151
|
+
const refused = new Set();
|
|
152
|
+
// Why the decision at `index` may not be enacted against the rows read so far; `open` refuses an unbound row outright.
|
|
153
|
+
const judge = (taskId, index, open) => {
|
|
154
|
+
const row = events[index];
|
|
155
|
+
const park = parks.get(taskId);
|
|
156
|
+
const failure = failures.get(taskId);
|
|
157
|
+
const parkBinding = recordedBinding(row.data.park);
|
|
158
|
+
const failureBinding = row.data.release === RECHECK_RELEASE ? recordedBinding(row.data.failure) : undefined;
|
|
159
|
+
if (parkBinding) {
|
|
160
|
+
if (!names(parkBinding, park))
|
|
161
|
+
return `bound to park ${bindingToken(parkBinding)} but the newest park is ${token(park)}`;
|
|
162
|
+
if ((failure ?? -1) > park)
|
|
163
|
+
return `bound to park ${bindingToken(parkBinding)} but the task failed since, at ${token(failure)}`;
|
|
164
|
+
if (row.data.release !== GATE_SATISFIED_RELEASE)
|
|
165
|
+
return undefined;
|
|
166
|
+
const gate = failedGateBeforePark(events, taskId, park);
|
|
167
|
+
return gate !== undefined && gate === row.data.gate ? undefined : `waive names gate ${String(row.data.gate ?? "none")} but park ${token(park)} failed ${gate ?? "no gate"}`;
|
|
168
|
+
}
|
|
169
|
+
if (failureBinding) {
|
|
170
|
+
return names(failureBinding, failure) && (park ?? -1) < failure
|
|
171
|
+
? undefined
|
|
172
|
+
: `bound to failure ${bindingToken(failureBinding)} but the newest failure is ${token(failure)} and the newest park ${token(park)}`;
|
|
173
|
+
}
|
|
174
|
+
// Enacted history written before decisions carried bindings stays readable unless a park or failure
|
|
175
|
+
// superseded it before its enactment. An open unbound row is refused whatever order it landed in.
|
|
176
|
+
if (!open && Math.max(park ?? -1, failure ?? -1) < index)
|
|
177
|
+
return undefined;
|
|
178
|
+
return `an unbound decision names no park token (the newest park is ${token(park)}) — refusing an unbound release`;
|
|
179
|
+
};
|
|
180
|
+
events.forEach((e, i) => {
|
|
181
|
+
const taskId = e.taskId;
|
|
182
|
+
if (!taskId)
|
|
183
|
+
return;
|
|
184
|
+
if (e.event === "task-approved") {
|
|
185
|
+
pending.set(taskId, [...(pending.get(taskId) ?? []), i]);
|
|
186
|
+
}
|
|
187
|
+
else if (e.event === APPROVAL_REFUSED) {
|
|
188
|
+
// A refusal voids the rows its `lines` name (every open row of its task when it names none).
|
|
189
|
+
const named = Array.isArray(e.data.lines) ? new Set(e.data.lines) : undefined;
|
|
190
|
+
const kept = (pending.get(taskId) ?? []).filter((at) => {
|
|
191
|
+
if (named !== undefined && !named.has(lineOf(at)))
|
|
192
|
+
return true;
|
|
193
|
+
refused.add(lineOf(at));
|
|
194
|
+
return false;
|
|
195
|
+
});
|
|
196
|
+
if (kept.length)
|
|
197
|
+
pending.set(taskId, kept);
|
|
198
|
+
else
|
|
199
|
+
pending.delete(taskId);
|
|
200
|
+
}
|
|
201
|
+
else if (ENACTS_DECISIONS.has(e.event)) {
|
|
202
|
+
for (const at of pending.get(taskId) ?? [])
|
|
203
|
+
if (judge(taskId, at, false) === undefined)
|
|
204
|
+
effective.add(lineOf(at));
|
|
205
|
+
pending.delete(taskId);
|
|
206
|
+
// The task moved on: its park and failure are consumed, so a later decision naming either is stale.
|
|
207
|
+
parks.delete(taskId);
|
|
208
|
+
failures.delete(taskId);
|
|
209
|
+
}
|
|
210
|
+
else if (e.event === "task-human" || e.event === "task-failed") {
|
|
211
|
+
(e.event === "task-human" ? parks : failures).set(taskId, i);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const open = [];
|
|
215
|
+
for (const [taskId, rows] of pending) {
|
|
216
|
+
for (const at of rows) {
|
|
217
|
+
const stale = judge(taskId, at, true);
|
|
218
|
+
if (stale === undefined)
|
|
219
|
+
effective.add(lineOf(at));
|
|
220
|
+
open.push({ taskId, line: lineOf(at), ...(stale === undefined ? {} : { stale }) });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { effective, open, refused };
|
|
224
|
+
}
|
|
225
|
+
/** The physical lines of the task-approved rows whose effects may apply (see foldDecisions). */
|
|
226
|
+
export const effectiveDecisions = (events) => foldDecisions(events).effective;
|
|
227
|
+
/**
|
|
228
|
+
* The journal as every decision reader must see it: task-approved rows that are not effective removed,
|
|
229
|
+
* every other row (and its physical line) kept. Order-only folds iterate this instead of skipping rows
|
|
230
|
+
* on their own.
|
|
231
|
+
*/
|
|
232
|
+
export function effectiveEvents(events) {
|
|
233
|
+
const effective = effectiveDecisions(events);
|
|
234
|
+
return events.filter((e, i) => e.event !== "task-approved" || effective.has(physicalLine(events, i)));
|
|
235
|
+
}
|
|
236
|
+
export function staleApprovals(events) {
|
|
237
|
+
const refused = new Map();
|
|
238
|
+
for (const { taskId, line, stale } of foldDecisions(events).open) {
|
|
239
|
+
if (stale === undefined)
|
|
240
|
+
continue;
|
|
241
|
+
const prior = refused.get(taskId);
|
|
242
|
+
refused.set(taskId, { reason: prior ? `${prior.reason}; #L${line} ${stale}` : `#L${line} ${stale}`, lines: [...(prior?.lines ?? []), line] });
|
|
243
|
+
}
|
|
244
|
+
return refused;
|
|
245
|
+
}
|
|
81
246
|
// OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
|
|
82
247
|
// preservation itself writes; task-human prose, branch heads and commit history are deliberately
|
|
83
248
|
// absent from this fold. Keep every row in journal order — one task can be recreated more than once,
|
|
@@ -100,18 +265,21 @@ export function preservedRefsByTask(events) {
|
|
|
100
265
|
// approval for the task. A whole-journal count re-parks an upheld task before its funded attempt can
|
|
101
266
|
// dispatch (measured live on run-20260726-213539), making a fresh journal the only escape. A T15
|
|
102
267
|
// replayMeasurement re-observes an interrupted round and is audit evidence, not a newly funded round.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
268
|
+
// OBS-1178: only an effective decision opens an engagement — a refused or unsound approval resets nothing.
|
|
269
|
+
// `rounds` narrows the decided rows AFTER the decision fold has read the whole journal (the daemon's
|
|
270
|
+
// decisive-round filter drops gate rows a park's failed gate is read from).
|
|
271
|
+
export function reviewRoundsSinceApproval(events, taskId, rounds = (decided) => decided) {
|
|
272
|
+
let drawn = 0;
|
|
273
|
+
for (const e of rounds(effectiveEvents(events))) {
|
|
106
274
|
if (e.taskId !== taskId)
|
|
107
275
|
continue;
|
|
108
276
|
if (e.event === "task-approved")
|
|
109
|
-
|
|
277
|
+
drawn = 0;
|
|
110
278
|
else if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === false
|
|
111
279
|
&& e.data.replayMeasurement !== true)
|
|
112
|
-
|
|
280
|
+
drawn++;
|
|
113
281
|
}
|
|
114
|
-
return
|
|
282
|
+
return drawn;
|
|
115
283
|
}
|
|
116
284
|
// OBS-189/OBS-254: the uphold brief is the operator's funded decision, not attempt state. ONE fold,
|
|
117
285
|
// two consumers — replayResumeState seeds it, and the daemon re-derives it from the journal at
|
|
@@ -545,24 +713,56 @@ export function normalizeGateFailure(details) {
|
|
|
545
713
|
// operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
|
|
546
714
|
// are excluded because the interrupted attempt already bought the result they confirm.
|
|
547
715
|
export const GATE_FINGERPRINT_CAP = 2;
|
|
716
|
+
/** The invocation a gate row's receipt names — the identity of the execution that observed it. */
|
|
717
|
+
export const receiptOrigin = (data) => {
|
|
718
|
+
const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
|
|
719
|
+
const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
|
|
720
|
+
return typeof id === "string" && id ? `invocation:${id}` : undefined;
|
|
721
|
+
};
|
|
722
|
+
// OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
|
|
723
|
+
// (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
|
|
724
|
+
// already bought, so each resolves to that row's origin — its receipt's invocation when it carries
|
|
725
|
+
// one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
|
|
726
|
+
// observation counts once however many copies the ledger holds, across resume. Only a fresh
|
|
727
|
+
// execution mints a new origin.
|
|
548
728
|
export function identicalGateFailures(events, taskId, gate, normalized) {
|
|
549
|
-
|
|
550
|
-
|
|
729
|
+
const counted = new Set();
|
|
730
|
+
const byAttempt = new Map();
|
|
731
|
+
const bySubject = new Map();
|
|
732
|
+
effectiveEvents(events).forEach((e, i) => {
|
|
551
733
|
if (e.taskId !== taskId)
|
|
552
|
-
|
|
553
|
-
if (e.event === "task-approved")
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
734
|
+
return;
|
|
735
|
+
if (e.event === "task-approved") {
|
|
736
|
+
counted.clear();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (e.event !== "gate-result" || e.data.gate !== gate)
|
|
740
|
+
return;
|
|
741
|
+
const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
|
|
742
|
+
const origin = typeof e.data.replayedFromAttempt === "number"
|
|
743
|
+
? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
|
|
744
|
+
: e.data.reused === true
|
|
745
|
+
? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
|
|
746
|
+
: receiptOrigin(e.data) ?? `row:${i}`;
|
|
747
|
+
if (typeof e.data.attempt === "number")
|
|
748
|
+
byAttempt.set(e.data.attempt, origin);
|
|
749
|
+
if (commit)
|
|
750
|
+
bySubject.set(commit, origin);
|
|
751
|
+
if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
|
|
558
752
|
&& normalizeGateFailure(e.data.details) === normalized)
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
return
|
|
753
|
+
counted.add(origin);
|
|
754
|
+
});
|
|
755
|
+
return counted.size;
|
|
562
756
|
}
|
|
757
|
+
// OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
|
|
758
|
+
// (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
|
|
759
|
+
// human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
|
|
760
|
+
// the failure fingerprint keep the raw text.
|
|
761
|
+
const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
762
|
+
export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
|
|
563
763
|
export function repairReachSinceApproval(events, taskId) {
|
|
564
764
|
const repairs = [];
|
|
565
|
-
for (const e of events) {
|
|
765
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
|
|
566
766
|
if (e.taskId !== taskId)
|
|
567
767
|
continue;
|
|
568
768
|
if (e.event === "task-approved") {
|
|
@@ -591,6 +791,9 @@ export function repairReachSinceApproval(events, taskId) {
|
|
|
591
791
|
else
|
|
592
792
|
repair.launched = true;
|
|
593
793
|
}
|
|
794
|
+
else if (BOOTSTRAP_DEATH.has(e.event)) {
|
|
795
|
+
repair.launched = false; // that launch died in its CLI's bootstrap: the repair's turn is still owed
|
|
796
|
+
}
|
|
594
797
|
else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
|
|
595
798
|
if (!repair.reached.includes(e.data.gate))
|
|
596
799
|
repair.reached.push(e.data.gate);
|
|
@@ -610,7 +813,7 @@ export function repairsSinceApproval(events, taskId) {
|
|
|
610
813
|
/** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
|
|
611
814
|
export function pendingRechecks(events) {
|
|
612
815
|
const pending = new Set();
|
|
613
|
-
for (const e of events) {
|
|
816
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck was never pending
|
|
614
817
|
if (!e.taskId)
|
|
615
818
|
continue;
|
|
616
819
|
if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
|
|
@@ -620,6 +823,7 @@ export function pendingRechecks(events) {
|
|
|
620
823
|
}
|
|
621
824
|
return pending;
|
|
622
825
|
}
|
|
826
|
+
// OBS-1178: only effective decisions (effectiveEvents) are pending actions; a refused or unsound row never is.
|
|
623
827
|
const ENACTED_BY = {
|
|
624
828
|
worker: ["task-dispatch", "worker-launch"],
|
|
625
829
|
battery: ["recheck-battery"],
|
|
@@ -635,7 +839,7 @@ const ENACTED_BY = {
|
|
|
635
839
|
*/
|
|
636
840
|
export function pendingApprovalActions(events) {
|
|
637
841
|
const pending = new Map();
|
|
638
|
-
for (const e of events) {
|
|
842
|
+
for (const e of effectiveEvents(events)) {
|
|
639
843
|
if (!e.taskId)
|
|
640
844
|
continue;
|
|
641
845
|
if (e.event === "task-approved") {
|
|
@@ -648,7 +852,7 @@ export function pendingApprovalActions(events) {
|
|
|
648
852
|
}
|
|
649
853
|
return pending;
|
|
650
854
|
}
|
|
651
|
-
function approvalAction(taskId, e) {
|
|
855
|
+
export function approvalAction(taskId, e) {
|
|
652
856
|
const { release, gate } = e.data;
|
|
653
857
|
const base = { taskId, ts: e.ts };
|
|
654
858
|
if (release === undefined)
|
|
@@ -675,15 +879,22 @@ function approvalAction(taskId, e) {
|
|
|
675
879
|
// prompt with the repair findings gone, or re-run the banned channel. worker-launch is appended only
|
|
676
880
|
// once the prompt has actually been delivered to a worker, which is the dispatch the decision governs.
|
|
677
881
|
const DECISION_SPENT = "worker-launch";
|
|
882
|
+
// OBS-1169: the daemon's rows closing a launch whose CLI died in its own bootstrap. That worker never
|
|
883
|
+
// read the brief, so the launch spent nothing: its decision, failure brief and repair turn survive it,
|
|
884
|
+
// and the dispatch it closes charges no attempt.
|
|
885
|
+
const BOOTSTRAP_DEATH = new Set(["bootstrap-retry", "bootstrap-failover"]);
|
|
678
886
|
function decisionForNextDispatch(events, taskId, event) {
|
|
679
887
|
let pending;
|
|
888
|
+
let spent;
|
|
680
889
|
for (const e of events) {
|
|
681
890
|
if (e.taskId !== taskId)
|
|
682
891
|
continue;
|
|
683
892
|
if (e.event === event)
|
|
684
893
|
pending = e;
|
|
685
894
|
else if (e.event === DECISION_SPENT)
|
|
686
|
-
pending = undefined;
|
|
895
|
+
[spent, pending] = [pending, undefined];
|
|
896
|
+
else if (BOOTSTRAP_DEATH.has(e.event))
|
|
897
|
+
pending = spent;
|
|
687
898
|
}
|
|
688
899
|
return pending;
|
|
689
900
|
}
|
|
@@ -702,25 +913,25 @@ function decisionForNextDispatch(events, taskId, event) {
|
|
|
702
913
|
* failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
|
|
703
914
|
* retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
|
|
704
915
|
* plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
|
|
705
|
-
* parked
|
|
706
|
-
*
|
|
916
|
+
* parked — v2.5.7's T11 looped four times on one hygiene oracle because every approval erased exactly
|
|
917
|
+
* the finding the fresh attempt was funded to fix. The operator's stated reasons ride after those rows
|
|
918
|
+
* as `standingRulings` (OBS-1150): no launch and no waive resets them.
|
|
707
919
|
*/
|
|
708
920
|
export function journaledFailureBrief(events, taskId) {
|
|
709
921
|
let rows = [];
|
|
710
|
-
|
|
922
|
+
let spent = [];
|
|
923
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound waive retires nothing
|
|
711
924
|
if (e.taskId !== taskId)
|
|
712
925
|
continue;
|
|
713
926
|
if (e.event === "worker-launch")
|
|
927
|
+
[spent, rows] = [rows, []];
|
|
928
|
+
else if (BOOTSTRAP_DEATH.has(e.event))
|
|
929
|
+
rows = [...spent, ...rows];
|
|
930
|
+
else if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE)
|
|
714
931
|
rows = [];
|
|
715
|
-
else if (e.event === "task-approved") {
|
|
716
|
-
if (e.data.release === GATE_SATISFIED_RELEASE)
|
|
717
|
-
rows = [];
|
|
718
|
-
else if (typeof e.data.reason === "string" && e.data.reason.trim())
|
|
719
|
-
rows.push(`approval: ${e.data.reason.trim()}`);
|
|
720
|
-
}
|
|
721
932
|
else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
|
|
722
933
|
&& typeof e.data.details === "string")
|
|
723
|
-
rows.push(`${e.data.gate}: ${e.data.details}`);
|
|
934
|
+
rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
|
|
724
935
|
else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
|
|
725
936
|
rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
|
|
726
937
|
}
|
|
@@ -728,7 +939,26 @@ export function journaledFailureBrief(events, taskId) {
|
|
|
728
939
|
rows.push(`dispatch: ${e.data.error}`);
|
|
729
940
|
}
|
|
730
941
|
}
|
|
731
|
-
return rows;
|
|
942
|
+
return [...rows, ...standingRulings(events, taskId).map((ruling) => `approval: ${ruling}`)];
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* OBS-1150: an operator's approval reason is a ruling on the TASK, not on the attempt it released.
|
|
946
|
+
* The failure rows above are spent at the next worker-launch and the review context once bound only
|
|
947
|
+
* the newest reason, so ruling A vanished at the first launch after it and a later ruling B replaced
|
|
948
|
+
* it. Every effective reason therefore stands, oldest first, for the whole run; a repeat is carried
|
|
949
|
+
* once. A gate-satisfied release accepts a gate's verdict rather than ruling on the work, so its
|
|
950
|
+
* reason adds no standing ruling — and, being no ruling, it retires none either.
|
|
951
|
+
*/
|
|
952
|
+
export function standingRulings(events, taskId) {
|
|
953
|
+
const rulings = [];
|
|
954
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval rules nothing
|
|
955
|
+
if (e.taskId !== taskId || e.event !== "task-approved" || e.data.release === GATE_SATISFIED_RELEASE)
|
|
956
|
+
continue;
|
|
957
|
+
const reason = typeof e.data.reason === "string" ? e.data.reason.trim() : "";
|
|
958
|
+
if (reason && !rulings.includes(reason))
|
|
959
|
+
rulings.push(reason);
|
|
960
|
+
}
|
|
961
|
+
return rulings;
|
|
732
962
|
}
|
|
733
963
|
/** Never infer a cross-path alias from a symbol, sentence, or hash. */
|
|
734
964
|
export function reviewFingerprintMatches(candidate, fingerprint) {
|
|
@@ -741,6 +971,17 @@ export function observedReviewFingerprints(finding) {
|
|
|
741
971
|
return [...new Set([finding.fingerprint, ...observed])];
|
|
742
972
|
}
|
|
743
973
|
const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
|
|
974
|
+
/** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
|
|
975
|
+
function settledAnchorRow(anchor) {
|
|
976
|
+
if (!anchor || typeof anchor !== "object")
|
|
977
|
+
return undefined;
|
|
978
|
+
const { path, line, body, disposition } = anchor;
|
|
979
|
+
if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
|
|
980
|
+
return undefined;
|
|
981
|
+
if (disposition !== "deferred" && disposition !== "resolved")
|
|
982
|
+
return undefined;
|
|
983
|
+
return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
|
|
984
|
+
}
|
|
744
985
|
function distinctReviewFinding(row) {
|
|
745
986
|
const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
|
|
746
987
|
const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
|
|
@@ -844,9 +1085,26 @@ export function carryReviewFindings(priors, rows) {
|
|
|
844
1085
|
* after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
|
|
845
1086
|
* row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
|
|
846
1087
|
*/
|
|
1088
|
+
/** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
|
|
1089
|
+
* never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
|
|
1090
|
+
export function priorJudgments(events, taskId) {
|
|
1091
|
+
const out = [];
|
|
1092
|
+
for (const e of events) {
|
|
1093
|
+
const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
|
|
1094
|
+
if (!j || typeof j !== "object")
|
|
1095
|
+
continue;
|
|
1096
|
+
const { commit, judge, criteria } = j;
|
|
1097
|
+
if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
|
|
1098
|
+
continue;
|
|
1099
|
+
const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
|
|
1100
|
+
&& Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
|
|
1101
|
+
out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
847
1105
|
export function outstandingReviewFindings(events, taskId) {
|
|
848
1106
|
let open = [];
|
|
849
|
-
for (const e of events) {
|
|
1107
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
|
|
850
1108
|
if (e.taskId !== taskId)
|
|
851
1109
|
continue;
|
|
852
1110
|
if (e.event === "task-approved") {
|
|
@@ -861,6 +1119,9 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
861
1119
|
continue;
|
|
862
1120
|
// A failed review can resolve one chain while re-raising another. Only observed, uniquely
|
|
863
1121
|
// matched spellings retire a chain; skipped/no-verdict rows were excluded above.
|
|
1122
|
+
// OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
|
|
1123
|
+
// retires whether it was carried in or restated by this same verdict.
|
|
1124
|
+
const retiredParents = new Set();
|
|
864
1125
|
if (Array.isArray(e.data.resolved)) {
|
|
865
1126
|
const resolved = e.data.resolved;
|
|
866
1127
|
const settled = new Set(resolved.flatMap((id) => {
|
|
@@ -868,8 +1129,45 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
868
1129
|
&& observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
|
|
869
1130
|
return matches.length === 1 ? matches : [];
|
|
870
1131
|
}));
|
|
1132
|
+
// OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
|
|
1133
|
+
for (const id of [...settled].flatMap(observedReviewFingerprints))
|
|
1134
|
+
retiredParents.add(id);
|
|
871
1135
|
open = open.filter((finding) => !settled.has(finding));
|
|
872
1136
|
}
|
|
1137
|
+
// OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
|
|
1138
|
+
// entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
|
|
1139
|
+
// so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
|
|
1140
|
+
// A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
|
|
1141
|
+
// id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
|
|
1142
|
+
// reraised list to check against, every link counts as a claim — fail closed.
|
|
1143
|
+
const rows = findingRows(e, "review");
|
|
1144
|
+
const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
|
|
1145
|
+
const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
|
|
1146
|
+
&& (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
|
|
1147
|
+
const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
|
|
1148
|
+
.map((row) => row.reraisedFrom);
|
|
1149
|
+
for (const finding of open) {
|
|
1150
|
+
if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
|
|
1151
|
+
continue;
|
|
1152
|
+
for (const id of observedReviewFingerprints(finding))
|
|
1153
|
+
retiredParents.add(id);
|
|
1154
|
+
}
|
|
1155
|
+
const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
|
|
1156
|
+
open = open.filter((finding) => !boundToRetired(finding));
|
|
1157
|
+
// OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
|
|
1158
|
+
// settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
|
|
1159
|
+
// retires it; a bare path coincidence binds nothing.
|
|
1160
|
+
if (Array.isArray(e.data.settledAnchors)) {
|
|
1161
|
+
const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
|
|
1162
|
+
const row = settledAnchorRow(anchor);
|
|
1163
|
+
// Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
|
|
1164
|
+
const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
|
|
1165
|
+
&& (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
|
|
1166
|
+
&& reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
|
|
1167
|
+
return matches.length === 1 ? matches : [];
|
|
1168
|
+
}));
|
|
1169
|
+
open = open.filter((finding) => !retired.has(finding));
|
|
1170
|
+
}
|
|
873
1171
|
if (e.data.pass !== false) {
|
|
874
1172
|
// a later review PASSED on this task: every finding it BLOCKED on is settled …
|
|
875
1173
|
open = open.filter(isDeferredFinding);
|
|
@@ -879,11 +1177,39 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
879
1177
|
// the very next round — the same silent drop by a different door.
|
|
880
1178
|
open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
|
|
881
1179
|
}
|
|
882
|
-
else
|
|
883
|
-
|
|
1180
|
+
else {
|
|
1181
|
+
// OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
|
|
1182
|
+
open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
|
|
1183
|
+
}
|
|
884
1184
|
}
|
|
885
1185
|
return open;
|
|
886
1186
|
}
|
|
1187
|
+
/**
|
|
1188
|
+
* OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
|
|
1189
|
+
* re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
|
|
1190
|
+
* count, so an unrelated finding sharing a path never lengthens it.
|
|
1191
|
+
*/
|
|
1192
|
+
export function reraisedReviewChains(events, taskId) {
|
|
1193
|
+
let verdicts = [];
|
|
1194
|
+
for (const e of effectiveEvents(events)) {
|
|
1195
|
+
if (e.taskId !== taskId)
|
|
1196
|
+
continue;
|
|
1197
|
+
if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
|
|
1198
|
+
verdicts = [];
|
|
1199
|
+
if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
|
|
1200
|
+
continue;
|
|
1201
|
+
if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
|
|
1202
|
+
continue;
|
|
1203
|
+
verdicts = e.data.pass === false ? [...verdicts, e] : [];
|
|
1204
|
+
}
|
|
1205
|
+
return outstandingReviewFindings(events, taskId)
|
|
1206
|
+
.filter((finding) => finding.class === "review:material")
|
|
1207
|
+
.map((finding) => ({
|
|
1208
|
+
finding,
|
|
1209
|
+
reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
|
|
1210
|
+
.some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
887
1213
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
888
1214
|
export function pendingRepairFindings(events, taskId) {
|
|
889
1215
|
const e = decisionForNextDispatch(events, taskId, "repair-attempt");
|
|
@@ -896,7 +1222,7 @@ export function pendingRepairFindings(events, taskId) {
|
|
|
896
1222
|
*/
|
|
897
1223
|
export function activeRetryBan(events, taskId, channel) {
|
|
898
1224
|
let pending;
|
|
899
|
-
for (const e of events) {
|
|
1225
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck lifts no ban
|
|
900
1226
|
if (e.taskId !== taskId)
|
|
901
1227
|
continue;
|
|
902
1228
|
if (e.event === "gate-fingerprint-cap")
|
|
@@ -982,6 +1308,60 @@ export function recordedTaskFailureKind(events, taskId) {
|
|
|
982
1308
|
}
|
|
983
1309
|
return undefined;
|
|
984
1310
|
}
|
|
1311
|
+
// OBS-1109: the `source` a resume harvest stamps on its worker-result and worker-result-harvested rows.
|
|
1312
|
+
export const RESUME_HARVEST_SOURCE = "resume";
|
|
1313
|
+
// Any harvest row — a live daemon's no-trailer synthesis or a resume's — means the attempt's result is
|
|
1314
|
+
// on record: whichever daemon died after it, the next resume gates that record and never redispatches.
|
|
1315
|
+
const isHarvested = (e) => e.event === "worker-result-harvested";
|
|
1316
|
+
const newestDispatch = (events, taskId) => {
|
|
1317
|
+
const rows = effectiveEvents(events).filter((e) => e.taskId === taskId);
|
|
1318
|
+
const at = rows.map((e) => e.event).lastIndexOf("task-dispatch");
|
|
1319
|
+
if (at < 0)
|
|
1320
|
+
return undefined;
|
|
1321
|
+
const assignment = DispatchAssignmentSchema.safeParse(rows[at].data.assignment);
|
|
1322
|
+
return { dispatch: rows[at], since: rows.slice(at + 1), assignment: assignment.success ? assignment.data : undefined };
|
|
1323
|
+
};
|
|
1324
|
+
// OBS-1109: the author of a harvested attempt. It holds through every gate result, park or release
|
|
1325
|
+
// recorded on that harvest — only a newer dispatch (new work, new author) ends it.
|
|
1326
|
+
export function resumeHarvestAuthor(events, taskId) {
|
|
1327
|
+
const newest = newestDispatch(events, taskId);
|
|
1328
|
+
return newest?.since.some(isHarvested) ? newest.assignment : undefined;
|
|
1329
|
+
}
|
|
1330
|
+
// OBS-1109: the newest dispatch of a task that no result, verdict or decision has closed yet — the
|
|
1331
|
+
// attempt a dead daemon left between worker-launch and worker-result. Only a launch row that recorded
|
|
1332
|
+
// its nonce and dispatch script is owned evidence; a legacy launch (or none) stays on the ordinary path.
|
|
1333
|
+
export function interruptedAttempt(events, taskId) {
|
|
1334
|
+
const newest = newestDispatch(events, taskId);
|
|
1335
|
+
if (!newest)
|
|
1336
|
+
return undefined;
|
|
1337
|
+
const { dispatch, since, assignment } = newest;
|
|
1338
|
+
// A declined harvest is on the ordinary recovery path: its pane is no longer evidence to spare.
|
|
1339
|
+
if (since.some((e) => ["gate-result", "task-done", "task-failed", "task-human", "task-approved", "resume-harvest-declined"].includes(e.event)))
|
|
1340
|
+
return undefined;
|
|
1341
|
+
const attempt = dispatch.data.attempt;
|
|
1342
|
+
if (!assignment || !Number.isInteger(attempt))
|
|
1343
|
+
return undefined; // no recorded author, no harvest
|
|
1344
|
+
const base = { attempt: attempt, assignment };
|
|
1345
|
+
if (since.some(isHarvested))
|
|
1346
|
+
return base;
|
|
1347
|
+
const launched = [...since].reverse().find((e) => e.event === "worker-launch" && e.data.attempt === attempt);
|
|
1348
|
+
const slot = launched?.data.slot;
|
|
1349
|
+
const launch = typeof launched?.data.nonce === "string" && typeof launched.data.dispatchScript === "string"
|
|
1350
|
+
&& typeof slot?.id === "string" && typeof slot.name === "string" && typeof slot.cwd === "string"
|
|
1351
|
+
? { nonce: launched.data.nonce, dispatchScript: launched.data.dispatchScript, slot: { id: slot.id, name: slot.name, cwd: slot.cwd } }
|
|
1352
|
+
: undefined;
|
|
1353
|
+
// A worker-result with no verdict after it — a live daemon's, or a resume harvest's torn off before
|
|
1354
|
+
// its harvest row — is a finished attempt whose gating never happened: finish its record, never redispatch.
|
|
1355
|
+
const resulted = since.find((e) => e.event === "worker-result");
|
|
1356
|
+
if (resulted) {
|
|
1357
|
+
return { ...base, ...(launch ? { launch } : {}), result: {
|
|
1358
|
+
finished: resulted.data.finished === true,
|
|
1359
|
+
summary: typeof resulted.data.summary === "string" ? resulted.data.summary : "",
|
|
1360
|
+
reaped: resulted.data.source === RESUME_HARVEST_SOURCE,
|
|
1361
|
+
} };
|
|
1362
|
+
}
|
|
1363
|
+
return launch ? { ...base, launch } : undefined;
|
|
1364
|
+
}
|
|
985
1365
|
// Runs can end and later resume in the same journal. The newest lifecycle marker decides whether
|
|
986
1366
|
// an unresolved task is still recoverable by this live daemon or belongs to an ended run.
|
|
987
1367
|
export function runHasEnded(events) {
|
|
@@ -1115,9 +1495,14 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
1115
1495
|
return undefined;
|
|
1116
1496
|
const origin = typeof start.data.graphDefinitionHash === "string" ? start.data.graphDefinitionHash : null;
|
|
1117
1497
|
let recorded = origin;
|
|
1498
|
+
// OBS-1178: an approval's own rehash moves the identity only while that approval is effective.
|
|
1499
|
+
const granted = new Set(effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
1500
|
+
.map((e) => `${e.taskId}\0${e.ts}`));
|
|
1118
1501
|
for (const e of events) {
|
|
1119
1502
|
if (e.event !== "graph-rehash")
|
|
1120
1503
|
continue;
|
|
1504
|
+
if (e.data.source === "approval" && e.data.replay !== true && !granted.has(`${e.taskId}\0${String(e.data.approval)}`))
|
|
1505
|
+
continue;
|
|
1121
1506
|
const audited = e.data.from === recorded || e.data.from === origin;
|
|
1122
1507
|
recorded = audited && typeof e.data.to === "string" ? e.data.to : null;
|
|
1123
1508
|
}
|
|
@@ -1154,7 +1539,7 @@ export const ScopeAmendmentSchema = z.object({
|
|
|
1154
1539
|
* older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
|
|
1155
1540
|
*/
|
|
1156
1541
|
export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
|
|
1157
|
-
const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
1542
|
+
const amendments = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
1158
1543
|
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
1159
1544
|
if (!amendments.length)
|
|
1160
1545
|
return graph;
|
|
@@ -1203,7 +1588,7 @@ export function replayScopeAmendments(graph, events, release = false, approvedDe
|
|
|
1203
1588
|
export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
|
|
1204
1589
|
const events = journal.read();
|
|
1205
1590
|
const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
|
|
1206
|
-
const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1591
|
+
const approvals = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1207
1592
|
for (const approval of approvals) {
|
|
1208
1593
|
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
1209
1594
|
if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
|
|
@@ -1282,14 +1667,19 @@ const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
|
|
|
1282
1667
|
// One JSONL reader for every append-only log: skip blanks, drop any line that
|
|
1283
1668
|
// won't parse, keeping everything before it intact.
|
|
1284
1669
|
function readJsonl(path) {
|
|
1285
|
-
|
|
1286
|
-
|
|
1670
|
+
return existsSync(path) ? parseJournalText(readFileSync(path, "utf8")) : [];
|
|
1671
|
+
}
|
|
1672
|
+
/** The journal reader rule over bytes in hand: skip blanks, drop a torn line, keep each row's physical line. */
|
|
1673
|
+
export function parseJournalText(raw) {
|
|
1287
1674
|
const out = [];
|
|
1288
|
-
for (const line of
|
|
1675
|
+
for (const [sourceIndex, line] of raw.split("\n").entries()) {
|
|
1289
1676
|
if (!line.trim())
|
|
1290
1677
|
continue;
|
|
1291
1678
|
try {
|
|
1292
|
-
|
|
1679
|
+
const row = JSON.parse(line);
|
|
1680
|
+
if (row && typeof row === "object")
|
|
1681
|
+
SOURCE_LINE.set(row, sourceIndex + 1);
|
|
1682
|
+
out.push(row);
|
|
1293
1683
|
}
|
|
1294
1684
|
catch {
|
|
1295
1685
|
// torn trailing write after a crash — ignore; everything before it is intact
|
|
@@ -1307,7 +1697,10 @@ function readJsonlSource(path) {
|
|
|
1307
1697
|
if (!line.trim())
|
|
1308
1698
|
continue;
|
|
1309
1699
|
try {
|
|
1310
|
-
|
|
1700
|
+
const raw = JSON.parse(line);
|
|
1701
|
+
if (raw && typeof raw === "object")
|
|
1702
|
+
SOURCE_LINE.set(raw, sourceIndex + 1);
|
|
1703
|
+
out.push({ sourceIndex, raw });
|
|
1311
1704
|
}
|
|
1312
1705
|
catch {
|
|
1313
1706
|
// torn trailing write after a crash — ignore; everything before it is intact
|
|
@@ -1367,6 +1760,78 @@ export function readPriorRunEvidence(repoRoot, tasks, opts = {}) {
|
|
|
1367
1760
|
&& current.get(evidence.taskId) === evidence.taskContentDigest),
|
|
1368
1761
|
};
|
|
1369
1762
|
}
|
|
1763
|
+
// OBS-1052(3): measured review no-verdict history, per reviewer channel, over the last ten COMPLETED
|
|
1764
|
+
// run journals. Advisory display only — nothing here feeds discovery, routing or the review rotation
|
|
1765
|
+
// (the run-scoped tally in run-gates is the only thing that retires a seat).
|
|
1766
|
+
export const REVIEW_NO_VERDICT_RUN_WINDOW = 10;
|
|
1767
|
+
export const REVIEW_NO_VERDICT_ADVISORY_AT = 2;
|
|
1768
|
+
// Unlike readJsonl, one unparseable row voids the whole journal: it may have been the missing event.
|
|
1769
|
+
function readJournalStrict(path) {
|
|
1770
|
+
try {
|
|
1771
|
+
return readFileSync(path, "utf8").split("\n").filter((line) => line.trim()).map((line) => {
|
|
1772
|
+
const row = JSON.parse(line);
|
|
1773
|
+
if (!row || typeof row !== "object" || typeof row.event !== "string")
|
|
1774
|
+
throw new Error("not a journal row");
|
|
1775
|
+
return row;
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
return undefined;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
export function readReviewNoVerdictHistory(repoRoot, window = REVIEW_NO_VERDICT_RUN_WINDOW) {
|
|
1783
|
+
const history = { runs: [], unreadable: [], counts: new Map() };
|
|
1784
|
+
const dir = runsDir(repoRoot);
|
|
1785
|
+
if (!existsSync(dir))
|
|
1786
|
+
return history;
|
|
1787
|
+
const runIds = readdirSync(dir)
|
|
1788
|
+
.filter((runId) => runId.startsWith("run-") && existsSync(join(dir, runId, "journal.jsonl")))
|
|
1789
|
+
.sort()
|
|
1790
|
+
.reverse();
|
|
1791
|
+
for (const runId of runIds) {
|
|
1792
|
+
// an unreadable journal holds its slot: skipping it would reach an eleventh run in its place
|
|
1793
|
+
if (history.runs.length + history.unreadable.length >= window)
|
|
1794
|
+
break;
|
|
1795
|
+
const events = readJournalStrict(join(dir, runId, "journal.jsonl"));
|
|
1796
|
+
if (!events) {
|
|
1797
|
+
history.unreadable.unshift(runId);
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
if (!runHasEnded(events))
|
|
1801
|
+
continue;
|
|
1802
|
+
history.runs.unshift(runId);
|
|
1803
|
+
for (const event of events) {
|
|
1804
|
+
const reviewer = event.data?.reviewer;
|
|
1805
|
+
if (typeof reviewer !== "string")
|
|
1806
|
+
continue;
|
|
1807
|
+
// Count each observation once: the terminal review gate-result repeats the last seat's
|
|
1808
|
+
// no-verdict note, so only the note counts and the gate row merely marks the seat as measured.
|
|
1809
|
+
if (event.event === "review-no-verdict")
|
|
1810
|
+
history.counts.set(reviewer, (history.counts.get(reviewer) ?? 0) + 1);
|
|
1811
|
+
else if (event.event === "gate-result" && event.data.gate === "review")
|
|
1812
|
+
history.counts.set(reviewer, history.counts.get(reviewer) ?? 0);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return history;
|
|
1816
|
+
}
|
|
1817
|
+
/** One row per measured channel (plus one naming unreadable journals); doctor prints all, Fleet the warn rows. */
|
|
1818
|
+
export function reviewNoVerdictRows(history) {
|
|
1819
|
+
const span = `the last ${history.runs.length} completed run${history.runs.length === 1 ? "" : "s"}`;
|
|
1820
|
+
const unreadable = history.unreadable.length;
|
|
1821
|
+
const rows = [...history.counts].sort(([a], [b]) => a.localeCompare(b)).map(([channel, n]) => {
|
|
1822
|
+
const counted = `${n} review no-verdict${n === 1 ? "" : "s"} in ${span}`;
|
|
1823
|
+
if (n >= REVIEW_NO_VERDICT_ADVISORY_AT) {
|
|
1824
|
+
return { channel, verdict: "warn", value: `${counted} — advisory; review eligibility unchanged` };
|
|
1825
|
+
}
|
|
1826
|
+
return unreadable
|
|
1827
|
+
? { channel, verdict: "warn", value: `unknown — at least ${n} in ${span}; ${unreadable} run journal${unreadable === 1 ? "" : "s"} unreadable` }
|
|
1828
|
+
: { channel, verdict: "pass", value: counted };
|
|
1829
|
+
});
|
|
1830
|
+
if (unreadable) {
|
|
1831
|
+
rows.push({ channel: "unreadable", verdict: "warn", value: `unknown — ${history.unreadable.join(", ")} did not parse; review no-verdicts there are unmeasured` });
|
|
1832
|
+
}
|
|
1833
|
+
return rows;
|
|
1834
|
+
}
|
|
1370
1835
|
// VIS-03 reset cursor — one trimmed runId line at .tickmarkr/profile-since; absent/empty ⇒ undefined.
|
|
1371
1836
|
// Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .tickmarkr/.
|
|
1372
1837
|
export function readProfileCursor(repoRoot) {
|
|
@@ -1473,7 +1938,7 @@ export class Journal {
|
|
|
1473
1938
|
const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
|
|
1474
1939
|
return preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
|
|
1475
1940
|
})()
|
|
1476
|
-
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(priorEvents).has(rowTaskId)
|
|
1941
|
+
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
|
|
1477
1942
|
? {
|
|
1478
1943
|
...inputData,
|
|
1479
1944
|
upheldFeedbackRestoredFor: rowTaskId,
|
|
@@ -1540,12 +2005,27 @@ export class Journal {
|
|
|
1540
2005
|
read() {
|
|
1541
2006
|
return readJsonl(this.journalPath);
|
|
1542
2007
|
}
|
|
2008
|
+
/** Parsed rows paired with their physical 1-based journal lines (OBS-1178 bindings name lines). */
|
|
2009
|
+
readSourced() {
|
|
2010
|
+
const rows = readJsonlSource(this.journalPath);
|
|
2011
|
+
return { events: rows.map((row) => row.raw), lines: rows.map((row) => row.sourceIndex + 1) };
|
|
2012
|
+
}
|
|
2013
|
+
/** OBS-1178: the binding of a task's newest park (or `task-failed`) row — what a decision on it names. */
|
|
2014
|
+
newestBinding(taskId, event = "task-human") {
|
|
2015
|
+
const { events, lines } = this.readSourced();
|
|
2016
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2017
|
+
const e = events[i];
|
|
2018
|
+
if (e.event === event && e.taskId === taskId)
|
|
2019
|
+
return typeof e.ts === "string" ? { line: lines[i], ts: e.ts } : undefined;
|
|
2020
|
+
}
|
|
2021
|
+
return undefined;
|
|
2022
|
+
}
|
|
1543
2023
|
readTracked() {
|
|
1544
2024
|
return trackJournalRows(this.runId, readJsonlSource(this.journalPath));
|
|
1545
2025
|
}
|
|
1546
2026
|
replayStatuses() {
|
|
1547
2027
|
const s = new Map();
|
|
1548
|
-
for (const e of this.read()) {
|
|
2028
|
+
for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound decision released nothing
|
|
1549
2029
|
if (!e.taskId)
|
|
1550
2030
|
continue;
|
|
1551
2031
|
if (e.event === "task-dispatch")
|
|
@@ -1608,7 +2088,7 @@ export class Journal {
|
|
|
1608
2088
|
}
|
|
1609
2089
|
replayResumeState() {
|
|
1610
2090
|
const m = new Map();
|
|
1611
|
-
const events = this.read();
|
|
2091
|
+
const events = effectiveEvents(this.read()); // OBS-1178: a refused or unsound decision funds no budget
|
|
1612
2092
|
// Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
|
|
1613
2093
|
// In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
|
|
1614
2094
|
const activeUpheldFeedback = upheldFeedbackByTask(events);
|
|
@@ -1639,6 +2119,8 @@ export class Journal {
|
|
|
1639
2119
|
const added = !st.tried.includes(key);
|
|
1640
2120
|
if (added)
|
|
1641
2121
|
st.tried.push(key);
|
|
2122
|
+
if (st.escalated)
|
|
2123
|
+
delete st.escalated[key];
|
|
1642
2124
|
lastDispatch.set(e.taskId, {
|
|
1643
2125
|
...(added ? { addedKey: key } : {}),
|
|
1644
2126
|
prevAssignment: st.lastAssignment,
|
|
@@ -1673,14 +2155,31 @@ export class Journal {
|
|
|
1673
2155
|
lastDispatch.delete(e.taskId);
|
|
1674
2156
|
}
|
|
1675
2157
|
}
|
|
1676
|
-
else if (e.event === "capacity-requeue") {
|
|
2158
|
+
else if (e.event === "capacity-requeue" || BOOTSTRAP_DEATH.has(e.event)) {
|
|
1677
2159
|
// OBS-1161: a capacity requeue is free — the daemon re-dispatches the SAME attempt on the SAME
|
|
1678
2160
|
// seat — so the dispatch it closes must not count, or a resume bills every busy-seat wait
|
|
1679
2161
|
// against MAX_ATTEMPTS. Only the attempt rewinds: the seat stays tried and in force (busy, not
|
|
1680
2162
|
// burned). Idempotent by the same rule as the scope rewind: only the OUTSTANDING dispatch.
|
|
2163
|
+
// OBS-1169: a bootstrap death bought no worker turn either, retried in place or failed over.
|
|
1681
2164
|
const st = m.get(e.taskId);
|
|
1682
2165
|
if (st && lastDispatch.delete(e.taskId))
|
|
1683
2166
|
st.attempts = Math.max(0, st.attempts - 1);
|
|
2167
|
+
if (st && e.event === "bootstrap-failover") {
|
|
2168
|
+
// OBS-1169: the failover's routing disposition outlives the process too — its escalated
|
|
2169
|
+
// siblings stay excluded with their reason, and a crash before the destination's dispatch
|
|
2170
|
+
// resumes ON that destination instead of relaunching the exhausted source channel.
|
|
2171
|
+
const reason = typeof e.data.reason === "string" ? e.data.reason : "vendor escalated";
|
|
2172
|
+
for (const k of Array.isArray(e.data.escalated) ? e.data.escalated : []) {
|
|
2173
|
+
if (typeof k === "string" && !st.tried.includes(k))
|
|
2174
|
+
st.escalated = { ...st.escalated, [k]: reason };
|
|
2175
|
+
}
|
|
2176
|
+
// The whole ADAPTER of `from` (a channel key, `adapter:model`) is excluded, sibling or none.
|
|
2177
|
+
const adapter = typeof e.data.from === "string" ? e.data.from.split(":")[0] : undefined;
|
|
2178
|
+
if (adapter && !st.escalatedAdapters?.includes(adapter))
|
|
2179
|
+
st.escalatedAdapters = [...(st.escalatedAdapters ?? []), adapter];
|
|
2180
|
+
const to = DispatchAssignmentSchema.safeParse(e.data.toAssignment);
|
|
2181
|
+
st.lastAssignment = to.success ? to.data : undefined;
|
|
2182
|
+
}
|
|
1684
2183
|
}
|
|
1685
2184
|
else if (e.event === "consult-verdict" && e.data.action === "reroute") {
|
|
1686
2185
|
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
@@ -1699,6 +2198,8 @@ export class Journal {
|
|
|
1699
2198
|
st.attempts = 0;
|
|
1700
2199
|
st.tried = [];
|
|
1701
2200
|
st.lastAssignment = undefined;
|
|
2201
|
+
delete st.escalated;
|
|
2202
|
+
delete st.escalatedAdapters;
|
|
1702
2203
|
}
|
|
1703
2204
|
}
|
|
1704
2205
|
else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
|
|
@@ -1752,7 +2253,7 @@ export class Journal {
|
|
|
1752
2253
|
const reviewSubjects = new Map();
|
|
1753
2254
|
const subjects = new Map();
|
|
1754
2255
|
const reviewWaivers = new Map();
|
|
1755
|
-
for (const e of this.read()) {
|
|
2256
|
+
for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound waive satisfies nothing
|
|
1756
2257
|
if (!e.taskId)
|
|
1757
2258
|
continue;
|
|
1758
2259
|
if (e.event === "task-dispatch") {
|
|
@@ -1812,7 +2313,7 @@ export class Journal {
|
|
|
1812
2313
|
// exclusively to replaySatisfiedGates(), whose typed release-marker contract above is unchanged.
|
|
1813
2314
|
replayCurrentAttemptGateResults() {
|
|
1814
2315
|
const replay = new Map();
|
|
1815
|
-
for (const e of this.read()) {
|
|
2316
|
+
for (const e of effectiveEvents(this.read())) {
|
|
1816
2317
|
if (!e.taskId)
|
|
1817
2318
|
continue;
|
|
1818
2319
|
if (e.event === "task-dispatch") {
|