tickmarkr 2.6.0 → 2.6.2
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 +14 -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 +3 -1
- package/dist/adapters/prompt.js +21 -3
- package/dist/adapters/registry.js +3 -3
- package/dist/adapters/types.d.ts +13 -4
- package/dist/adapters/types.js +16 -0
- package/dist/cli/commands/approve.d.ts +5 -1
- package/dist/cli/commands/approve.js +66 -23
- package/dist/cli/commands/compile.js +13 -3
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +11 -3
- package/dist/cli/commands/fleet.js +71 -8
- package/dist/cli/commands/plan.js +7 -3
- package/dist/cli/commands/report.js +18 -2
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/status.js +37 -22
- package/dist/cli/help.d.ts +4 -0
- package/dist/cli/help.js +11 -2
- package/dist/config/config.d.ts +20 -0
- package/dist/config/config.js +47 -8
- package/dist/config/fleet-overlay.d.ts +1 -0
- package/dist/config/fleet-overlay.js +56 -0
- package/dist/drivers/orca.d.ts +26 -1
- package/dist/drivers/orca.js +199 -61
- 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 +2 -1
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/baseline.d.ts +12 -1
- package/dist/gates/baseline.js +11 -4
- package/dist/gates/cache.d.ts +3 -1
- package/dist/gates/cache.js +10 -3
- package/dist/gates/llm.d.ts +5 -4
- package/dist/gates/llm.js +17 -14
- package/dist/gates/review.d.ts +8 -0
- package/dist/gates/review.js +47 -15
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +34 -17
- package/dist/gates/test-manifest.d.ts +14 -0
- package/dist/gates/test-manifest.js +33 -6
- package/dist/graph/graph.d.ts +6 -2
- package/dist/graph/graph.js +15 -4
- 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/role-pick.d.ts +16 -0
- package/dist/route/role-pick.js +15 -0
- package/dist/route/router.d.ts +14 -0
- package/dist/route/router.js +39 -16
- package/dist/run/consult.d.ts +13 -1
- package/dist/run/consult.js +19 -14
- package/dist/run/daemon.d.ts +46 -2
- package/dist/run/daemon.js +964 -190
- package/dist/run/git.d.ts +44 -1
- package/dist/run/git.js +103 -5
- package/dist/run/host-health.d.ts +20 -0
- package/dist/run/host-health.js +64 -0
- package/dist/run/journal.d.ts +126 -3
- package/dist/run/journal.js +418 -36
- package/dist/run/merge.d.ts +3 -1
- package/dist/run/merge.js +3 -2
- package/dist/run/operator-state.d.ts +24 -2
- package/dist/run/operator-state.js +41 -5
- package/dist/run/operator-summary.d.ts +3 -0
- package/dist/run/operator-summary.js +3 -1
- package/dist/run/protocol.d.ts +31 -1
- package/dist/run/protocol.js +3 -1
- package/dist/run/stall.d.ts +38 -2
- package/dist/run/stall.js +276 -6
- package/dist/run/supervision.d.ts +7 -1
- package/dist/run/supervision.js +5 -2
- package/dist/tui/cockpit/board.d.ts +1 -1
- package/dist/tui/cockpit/board.js +30 -22
- package/dist/tui/cockpit/decision-actions.d.ts +8 -5
- package/dist/tui/cockpit/decision-actions.js +55 -32
- package/dist/tui/cockpit/derive.d.ts +2 -0
- package/dist/tui/cockpit/derive.js +17 -2
- package/dist/tui/cockpit/live-runtime.d.ts +10 -0
- package/dist/tui/cockpit/live-runtime.js +50 -3
- package/dist/tui/cockpit/live-store.d.ts +1 -0
- package/dist/tui/cockpit/live-store.js +31 -8
- package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
- package/dist/tui/cockpit/run-cockpit.js +28 -2
- package/dist/tui/cockpit/run-view.d.ts +11 -7
- package/dist/tui/cockpit/run-view.js +69 -15
- package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
- package/dist/tui/cockpit/setup-cockpit.js +6 -3
- package/dist/tui/ink/fleet-app.d.ts +15 -3
- package/dist/tui/ink/fleet-app.js +91 -22
- package/package.json +2 -1
- package/schema/config.schema.json +818 -0
- package/skills/tickmarkr-loop/SKILL.md +8 -2
- package/skills/tickmarkr-overseer/SKILL.md +85 -6
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -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
|
|
@@ -547,7 +715,7 @@ export function normalizeGateFailure(details) {
|
|
|
547
715
|
export const GATE_FINGERPRINT_CAP = 2;
|
|
548
716
|
export function identicalGateFailures(events, taskId, gate, normalized) {
|
|
549
717
|
let n = 0;
|
|
550
|
-
for (const e of events) {
|
|
718
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval is no new engagement
|
|
551
719
|
if (e.taskId !== taskId)
|
|
552
720
|
continue;
|
|
553
721
|
if (e.event === "task-approved")
|
|
@@ -562,7 +730,7 @@ export function identicalGateFailures(events, taskId, gate, normalized) {
|
|
|
562
730
|
}
|
|
563
731
|
export function repairReachSinceApproval(events, taskId) {
|
|
564
732
|
const repairs = [];
|
|
565
|
-
for (const e of events) {
|
|
733
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
|
|
566
734
|
if (e.taskId !== taskId)
|
|
567
735
|
continue;
|
|
568
736
|
if (e.event === "task-approved") {
|
|
@@ -591,6 +759,9 @@ export function repairReachSinceApproval(events, taskId) {
|
|
|
591
759
|
else
|
|
592
760
|
repair.launched = true;
|
|
593
761
|
}
|
|
762
|
+
else if (BOOTSTRAP_DEATH.has(e.event)) {
|
|
763
|
+
repair.launched = false; // that launch died in its CLI's bootstrap: the repair's turn is still owed
|
|
764
|
+
}
|
|
594
765
|
else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
|
|
595
766
|
if (!repair.reached.includes(e.data.gate))
|
|
596
767
|
repair.reached.push(e.data.gate);
|
|
@@ -610,7 +781,7 @@ export function repairsSinceApproval(events, taskId) {
|
|
|
610
781
|
/** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
|
|
611
782
|
export function pendingRechecks(events) {
|
|
612
783
|
const pending = new Set();
|
|
613
|
-
for (const e of events) {
|
|
784
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck was never pending
|
|
614
785
|
if (!e.taskId)
|
|
615
786
|
continue;
|
|
616
787
|
if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
|
|
@@ -620,6 +791,7 @@ export function pendingRechecks(events) {
|
|
|
620
791
|
}
|
|
621
792
|
return pending;
|
|
622
793
|
}
|
|
794
|
+
// OBS-1178: only effective decisions (effectiveEvents) are pending actions; a refused or unsound row never is.
|
|
623
795
|
const ENACTED_BY = {
|
|
624
796
|
worker: ["task-dispatch", "worker-launch"],
|
|
625
797
|
battery: ["recheck-battery"],
|
|
@@ -635,7 +807,7 @@ const ENACTED_BY = {
|
|
|
635
807
|
*/
|
|
636
808
|
export function pendingApprovalActions(events) {
|
|
637
809
|
const pending = new Map();
|
|
638
|
-
for (const e of events) {
|
|
810
|
+
for (const e of effectiveEvents(events)) {
|
|
639
811
|
if (!e.taskId)
|
|
640
812
|
continue;
|
|
641
813
|
if (e.event === "task-approved") {
|
|
@@ -648,7 +820,7 @@ export function pendingApprovalActions(events) {
|
|
|
648
820
|
}
|
|
649
821
|
return pending;
|
|
650
822
|
}
|
|
651
|
-
function approvalAction(taskId, e) {
|
|
823
|
+
export function approvalAction(taskId, e) {
|
|
652
824
|
const { release, gate } = e.data;
|
|
653
825
|
const base = { taskId, ts: e.ts };
|
|
654
826
|
if (release === undefined)
|
|
@@ -675,15 +847,22 @@ function approvalAction(taskId, e) {
|
|
|
675
847
|
// prompt with the repair findings gone, or re-run the banned channel. worker-launch is appended only
|
|
676
848
|
// once the prompt has actually been delivered to a worker, which is the dispatch the decision governs.
|
|
677
849
|
const DECISION_SPENT = "worker-launch";
|
|
850
|
+
// OBS-1169: the daemon's rows closing a launch whose CLI died in its own bootstrap. That worker never
|
|
851
|
+
// read the brief, so the launch spent nothing: its decision, failure brief and repair turn survive it,
|
|
852
|
+
// and the dispatch it closes charges no attempt.
|
|
853
|
+
const BOOTSTRAP_DEATH = new Set(["bootstrap-retry", "bootstrap-failover"]);
|
|
678
854
|
function decisionForNextDispatch(events, taskId, event) {
|
|
679
855
|
let pending;
|
|
856
|
+
let spent;
|
|
680
857
|
for (const e of events) {
|
|
681
858
|
if (e.taskId !== taskId)
|
|
682
859
|
continue;
|
|
683
860
|
if (e.event === event)
|
|
684
861
|
pending = e;
|
|
685
862
|
else if (e.event === DECISION_SPENT)
|
|
686
|
-
pending = undefined;
|
|
863
|
+
[spent, pending] = [pending, undefined];
|
|
864
|
+
else if (BOOTSTRAP_DEATH.has(e.event))
|
|
865
|
+
pending = spent;
|
|
687
866
|
}
|
|
688
867
|
return pending;
|
|
689
868
|
}
|
|
@@ -702,22 +881,22 @@ function decisionForNextDispatch(events, taskId, event) {
|
|
|
702
881
|
* failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
|
|
703
882
|
* retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
|
|
704
883
|
* plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
|
|
705
|
-
* parked
|
|
706
|
-
*
|
|
884
|
+
* parked — v2.5.7's T11 looped four times on one hygiene oracle because every approval erased exactly
|
|
885
|
+
* the finding the fresh attempt was funded to fix. The operator's stated reasons ride after those rows
|
|
886
|
+
* as `standingRulings` (OBS-1150): no launch and no waive resets them.
|
|
707
887
|
*/
|
|
708
888
|
export function journaledFailureBrief(events, taskId) {
|
|
709
889
|
let rows = [];
|
|
710
|
-
|
|
890
|
+
let spent = [];
|
|
891
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound waive retires nothing
|
|
711
892
|
if (e.taskId !== taskId)
|
|
712
893
|
continue;
|
|
713
894
|
if (e.event === "worker-launch")
|
|
895
|
+
[spent, rows] = [rows, []];
|
|
896
|
+
else if (BOOTSTRAP_DEATH.has(e.event))
|
|
897
|
+
rows = [...spent, ...rows];
|
|
898
|
+
else if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE)
|
|
714
899
|
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
900
|
else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
|
|
722
901
|
&& typeof e.data.details === "string")
|
|
723
902
|
rows.push(`${e.data.gate}: ${e.data.details}`);
|
|
@@ -728,7 +907,26 @@ export function journaledFailureBrief(events, taskId) {
|
|
|
728
907
|
rows.push(`dispatch: ${e.data.error}`);
|
|
729
908
|
}
|
|
730
909
|
}
|
|
731
|
-
return rows;
|
|
910
|
+
return [...rows, ...standingRulings(events, taskId).map((ruling) => `approval: ${ruling}`)];
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* OBS-1150: an operator's approval reason is a ruling on the TASK, not on the attempt it released.
|
|
914
|
+
* The failure rows above are spent at the next worker-launch and the review context once bound only
|
|
915
|
+
* the newest reason, so ruling A vanished at the first launch after it and a later ruling B replaced
|
|
916
|
+
* it. Every effective reason therefore stands, oldest first, for the whole run; a repeat is carried
|
|
917
|
+
* once. A gate-satisfied release accepts a gate's verdict rather than ruling on the work, so its
|
|
918
|
+
* reason adds no standing ruling — and, being no ruling, it retires none either.
|
|
919
|
+
*/
|
|
920
|
+
export function standingRulings(events, taskId) {
|
|
921
|
+
const rulings = [];
|
|
922
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval rules nothing
|
|
923
|
+
if (e.taskId !== taskId || e.event !== "task-approved" || e.data.release === GATE_SATISFIED_RELEASE)
|
|
924
|
+
continue;
|
|
925
|
+
const reason = typeof e.data.reason === "string" ? e.data.reason.trim() : "";
|
|
926
|
+
if (reason && !rulings.includes(reason))
|
|
927
|
+
rulings.push(reason);
|
|
928
|
+
}
|
|
929
|
+
return rulings;
|
|
732
930
|
}
|
|
733
931
|
/** Never infer a cross-path alias from a symbol, sentence, or hash. */
|
|
734
932
|
export function reviewFingerprintMatches(candidate, fingerprint) {
|
|
@@ -846,7 +1044,7 @@ export function carryReviewFindings(priors, rows) {
|
|
|
846
1044
|
*/
|
|
847
1045
|
export function outstandingReviewFindings(events, taskId) {
|
|
848
1046
|
let open = [];
|
|
849
|
-
for (const e of events) {
|
|
1047
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
|
|
850
1048
|
if (e.taskId !== taskId)
|
|
851
1049
|
continue;
|
|
852
1050
|
if (e.event === "task-approved") {
|
|
@@ -896,7 +1094,7 @@ export function pendingRepairFindings(events, taskId) {
|
|
|
896
1094
|
*/
|
|
897
1095
|
export function activeRetryBan(events, taskId, channel) {
|
|
898
1096
|
let pending;
|
|
899
|
-
for (const e of events) {
|
|
1097
|
+
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck lifts no ban
|
|
900
1098
|
if (e.taskId !== taskId)
|
|
901
1099
|
continue;
|
|
902
1100
|
if (e.event === "gate-fingerprint-cap")
|
|
@@ -982,6 +1180,60 @@ export function recordedTaskFailureKind(events, taskId) {
|
|
|
982
1180
|
}
|
|
983
1181
|
return undefined;
|
|
984
1182
|
}
|
|
1183
|
+
// OBS-1109: the `source` a resume harvest stamps on its worker-result and worker-result-harvested rows.
|
|
1184
|
+
export const RESUME_HARVEST_SOURCE = "resume";
|
|
1185
|
+
// Any harvest row — a live daemon's no-trailer synthesis or a resume's — means the attempt's result is
|
|
1186
|
+
// on record: whichever daemon died after it, the next resume gates that record and never redispatches.
|
|
1187
|
+
const isHarvested = (e) => e.event === "worker-result-harvested";
|
|
1188
|
+
const newestDispatch = (events, taskId) => {
|
|
1189
|
+
const rows = effectiveEvents(events).filter((e) => e.taskId === taskId);
|
|
1190
|
+
const at = rows.map((e) => e.event).lastIndexOf("task-dispatch");
|
|
1191
|
+
if (at < 0)
|
|
1192
|
+
return undefined;
|
|
1193
|
+
const assignment = DispatchAssignmentSchema.safeParse(rows[at].data.assignment);
|
|
1194
|
+
return { dispatch: rows[at], since: rows.slice(at + 1), assignment: assignment.success ? assignment.data : undefined };
|
|
1195
|
+
};
|
|
1196
|
+
// OBS-1109: the author of a harvested attempt. It holds through every gate result, park or release
|
|
1197
|
+
// recorded on that harvest — only a newer dispatch (new work, new author) ends it.
|
|
1198
|
+
export function resumeHarvestAuthor(events, taskId) {
|
|
1199
|
+
const newest = newestDispatch(events, taskId);
|
|
1200
|
+
return newest?.since.some(isHarvested) ? newest.assignment : undefined;
|
|
1201
|
+
}
|
|
1202
|
+
// OBS-1109: the newest dispatch of a task that no result, verdict or decision has closed yet — the
|
|
1203
|
+
// attempt a dead daemon left between worker-launch and worker-result. Only a launch row that recorded
|
|
1204
|
+
// its nonce and dispatch script is owned evidence; a legacy launch (or none) stays on the ordinary path.
|
|
1205
|
+
export function interruptedAttempt(events, taskId) {
|
|
1206
|
+
const newest = newestDispatch(events, taskId);
|
|
1207
|
+
if (!newest)
|
|
1208
|
+
return undefined;
|
|
1209
|
+
const { dispatch, since, assignment } = newest;
|
|
1210
|
+
// A declined harvest is on the ordinary recovery path: its pane is no longer evidence to spare.
|
|
1211
|
+
if (since.some((e) => ["gate-result", "task-done", "task-failed", "task-human", "task-approved", "resume-harvest-declined"].includes(e.event)))
|
|
1212
|
+
return undefined;
|
|
1213
|
+
const attempt = dispatch.data.attempt;
|
|
1214
|
+
if (!assignment || !Number.isInteger(attempt))
|
|
1215
|
+
return undefined; // no recorded author, no harvest
|
|
1216
|
+
const base = { attempt: attempt, assignment };
|
|
1217
|
+
if (since.some(isHarvested))
|
|
1218
|
+
return base;
|
|
1219
|
+
const launched = [...since].reverse().find((e) => e.event === "worker-launch" && e.data.attempt === attempt);
|
|
1220
|
+
const slot = launched?.data.slot;
|
|
1221
|
+
const launch = typeof launched?.data.nonce === "string" && typeof launched.data.dispatchScript === "string"
|
|
1222
|
+
&& typeof slot?.id === "string" && typeof slot.name === "string" && typeof slot.cwd === "string"
|
|
1223
|
+
? { nonce: launched.data.nonce, dispatchScript: launched.data.dispatchScript, slot: { id: slot.id, name: slot.name, cwd: slot.cwd } }
|
|
1224
|
+
: undefined;
|
|
1225
|
+
// A worker-result with no verdict after it — a live daemon's, or a resume harvest's torn off before
|
|
1226
|
+
// its harvest row — is a finished attempt whose gating never happened: finish its record, never redispatch.
|
|
1227
|
+
const resulted = since.find((e) => e.event === "worker-result");
|
|
1228
|
+
if (resulted) {
|
|
1229
|
+
return { ...base, ...(launch ? { launch } : {}), result: {
|
|
1230
|
+
finished: resulted.data.finished === true,
|
|
1231
|
+
summary: typeof resulted.data.summary === "string" ? resulted.data.summary : "",
|
|
1232
|
+
reaped: resulted.data.source === RESUME_HARVEST_SOURCE,
|
|
1233
|
+
} };
|
|
1234
|
+
}
|
|
1235
|
+
return launch ? { ...base, launch } : undefined;
|
|
1236
|
+
}
|
|
985
1237
|
// Runs can end and later resume in the same journal. The newest lifecycle marker decides whether
|
|
986
1238
|
// an unresolved task is still recoverable by this live daemon or belongs to an ended run.
|
|
987
1239
|
export function runHasEnded(events) {
|
|
@@ -1115,9 +1367,14 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
1115
1367
|
return undefined;
|
|
1116
1368
|
const origin = typeof start.data.graphDefinitionHash === "string" ? start.data.graphDefinitionHash : null;
|
|
1117
1369
|
let recorded = origin;
|
|
1370
|
+
// OBS-1178: an approval's own rehash moves the identity only while that approval is effective.
|
|
1371
|
+
const granted = new Set(effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
1372
|
+
.map((e) => `${e.taskId}\0${e.ts}`));
|
|
1118
1373
|
for (const e of events) {
|
|
1119
1374
|
if (e.event !== "graph-rehash")
|
|
1120
1375
|
continue;
|
|
1376
|
+
if (e.data.source === "approval" && e.data.replay !== true && !granted.has(`${e.taskId}\0${String(e.data.approval)}`))
|
|
1377
|
+
continue;
|
|
1121
1378
|
const audited = e.data.from === recorded || e.data.from === origin;
|
|
1122
1379
|
recorded = audited && typeof e.data.to === "string" ? e.data.to : null;
|
|
1123
1380
|
}
|
|
@@ -1154,7 +1411,7 @@ export const ScopeAmendmentSchema = z.object({
|
|
|
1154
1411
|
* older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
|
|
1155
1412
|
*/
|
|
1156
1413
|
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")
|
|
1414
|
+
const amendments = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
1158
1415
|
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
1159
1416
|
if (!amendments.length)
|
|
1160
1417
|
return graph;
|
|
@@ -1203,7 +1460,7 @@ export function replayScopeAmendments(graph, events, release = false, approvedDe
|
|
|
1203
1460
|
export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
|
|
1204
1461
|
const events = journal.read();
|
|
1205
1462
|
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");
|
|
1463
|
+
const approvals = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1207
1464
|
for (const approval of approvals) {
|
|
1208
1465
|
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
1209
1466
|
if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
|
|
@@ -1282,14 +1539,19 @@ const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
|
|
|
1282
1539
|
// One JSONL reader for every append-only log: skip blanks, drop any line that
|
|
1283
1540
|
// won't parse, keeping everything before it intact.
|
|
1284
1541
|
function readJsonl(path) {
|
|
1285
|
-
|
|
1286
|
-
|
|
1542
|
+
return existsSync(path) ? parseJournalText(readFileSync(path, "utf8")) : [];
|
|
1543
|
+
}
|
|
1544
|
+
/** The journal reader rule over bytes in hand: skip blanks, drop a torn line, keep each row's physical line. */
|
|
1545
|
+
export function parseJournalText(raw) {
|
|
1287
1546
|
const out = [];
|
|
1288
|
-
for (const line of
|
|
1547
|
+
for (const [sourceIndex, line] of raw.split("\n").entries()) {
|
|
1289
1548
|
if (!line.trim())
|
|
1290
1549
|
continue;
|
|
1291
1550
|
try {
|
|
1292
|
-
|
|
1551
|
+
const row = JSON.parse(line);
|
|
1552
|
+
if (row && typeof row === "object")
|
|
1553
|
+
SOURCE_LINE.set(row, sourceIndex + 1);
|
|
1554
|
+
out.push(row);
|
|
1293
1555
|
}
|
|
1294
1556
|
catch {
|
|
1295
1557
|
// torn trailing write after a crash — ignore; everything before it is intact
|
|
@@ -1307,7 +1569,10 @@ function readJsonlSource(path) {
|
|
|
1307
1569
|
if (!line.trim())
|
|
1308
1570
|
continue;
|
|
1309
1571
|
try {
|
|
1310
|
-
|
|
1572
|
+
const raw = JSON.parse(line);
|
|
1573
|
+
if (raw && typeof raw === "object")
|
|
1574
|
+
SOURCE_LINE.set(raw, sourceIndex + 1);
|
|
1575
|
+
out.push({ sourceIndex, raw });
|
|
1311
1576
|
}
|
|
1312
1577
|
catch {
|
|
1313
1578
|
// torn trailing write after a crash — ignore; everything before it is intact
|
|
@@ -1367,6 +1632,78 @@ export function readPriorRunEvidence(repoRoot, tasks, opts = {}) {
|
|
|
1367
1632
|
&& current.get(evidence.taskId) === evidence.taskContentDigest),
|
|
1368
1633
|
};
|
|
1369
1634
|
}
|
|
1635
|
+
// OBS-1052(3): measured review no-verdict history, per reviewer channel, over the last ten COMPLETED
|
|
1636
|
+
// run journals. Advisory display only — nothing here feeds discovery, routing or the review rotation
|
|
1637
|
+
// (the run-scoped tally in run-gates is the only thing that retires a seat).
|
|
1638
|
+
export const REVIEW_NO_VERDICT_RUN_WINDOW = 10;
|
|
1639
|
+
export const REVIEW_NO_VERDICT_ADVISORY_AT = 2;
|
|
1640
|
+
// Unlike readJsonl, one unparseable row voids the whole journal: it may have been the missing event.
|
|
1641
|
+
function readJournalStrict(path) {
|
|
1642
|
+
try {
|
|
1643
|
+
return readFileSync(path, "utf8").split("\n").filter((line) => line.trim()).map((line) => {
|
|
1644
|
+
const row = JSON.parse(line);
|
|
1645
|
+
if (!row || typeof row !== "object" || typeof row.event !== "string")
|
|
1646
|
+
throw new Error("not a journal row");
|
|
1647
|
+
return row;
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
catch {
|
|
1651
|
+
return undefined;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
export function readReviewNoVerdictHistory(repoRoot, window = REVIEW_NO_VERDICT_RUN_WINDOW) {
|
|
1655
|
+
const history = { runs: [], unreadable: [], counts: new Map() };
|
|
1656
|
+
const dir = runsDir(repoRoot);
|
|
1657
|
+
if (!existsSync(dir))
|
|
1658
|
+
return history;
|
|
1659
|
+
const runIds = readdirSync(dir)
|
|
1660
|
+
.filter((runId) => runId.startsWith("run-") && existsSync(join(dir, runId, "journal.jsonl")))
|
|
1661
|
+
.sort()
|
|
1662
|
+
.reverse();
|
|
1663
|
+
for (const runId of runIds) {
|
|
1664
|
+
// an unreadable journal holds its slot: skipping it would reach an eleventh run in its place
|
|
1665
|
+
if (history.runs.length + history.unreadable.length >= window)
|
|
1666
|
+
break;
|
|
1667
|
+
const events = readJournalStrict(join(dir, runId, "journal.jsonl"));
|
|
1668
|
+
if (!events) {
|
|
1669
|
+
history.unreadable.unshift(runId);
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (!runHasEnded(events))
|
|
1673
|
+
continue;
|
|
1674
|
+
history.runs.unshift(runId);
|
|
1675
|
+
for (const event of events) {
|
|
1676
|
+
const reviewer = event.data?.reviewer;
|
|
1677
|
+
if (typeof reviewer !== "string")
|
|
1678
|
+
continue;
|
|
1679
|
+
// Count each observation once: the terminal review gate-result repeats the last seat's
|
|
1680
|
+
// no-verdict note, so only the note counts and the gate row merely marks the seat as measured.
|
|
1681
|
+
if (event.event === "review-no-verdict")
|
|
1682
|
+
history.counts.set(reviewer, (history.counts.get(reviewer) ?? 0) + 1);
|
|
1683
|
+
else if (event.event === "gate-result" && event.data.gate === "review")
|
|
1684
|
+
history.counts.set(reviewer, history.counts.get(reviewer) ?? 0);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return history;
|
|
1688
|
+
}
|
|
1689
|
+
/** One row per measured channel (plus one naming unreadable journals); doctor prints all, Fleet the warn rows. */
|
|
1690
|
+
export function reviewNoVerdictRows(history) {
|
|
1691
|
+
const span = `the last ${history.runs.length} completed run${history.runs.length === 1 ? "" : "s"}`;
|
|
1692
|
+
const unreadable = history.unreadable.length;
|
|
1693
|
+
const rows = [...history.counts].sort(([a], [b]) => a.localeCompare(b)).map(([channel, n]) => {
|
|
1694
|
+
const counted = `${n} review no-verdict${n === 1 ? "" : "s"} in ${span}`;
|
|
1695
|
+
if (n >= REVIEW_NO_VERDICT_ADVISORY_AT) {
|
|
1696
|
+
return { channel, verdict: "warn", value: `${counted} — advisory; review eligibility unchanged` };
|
|
1697
|
+
}
|
|
1698
|
+
return unreadable
|
|
1699
|
+
? { channel, verdict: "warn", value: `unknown — at least ${n} in ${span}; ${unreadable} run journal${unreadable === 1 ? "" : "s"} unreadable` }
|
|
1700
|
+
: { channel, verdict: "pass", value: counted };
|
|
1701
|
+
});
|
|
1702
|
+
if (unreadable) {
|
|
1703
|
+
rows.push({ channel: "unreadable", verdict: "warn", value: `unknown — ${history.unreadable.join(", ")} did not parse; review no-verdicts there are unmeasured` });
|
|
1704
|
+
}
|
|
1705
|
+
return rows;
|
|
1706
|
+
}
|
|
1370
1707
|
// VIS-03 reset cursor — one trimmed runId line at .tickmarkr/profile-since; absent/empty ⇒ undefined.
|
|
1371
1708
|
// Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .tickmarkr/.
|
|
1372
1709
|
export function readProfileCursor(repoRoot) {
|
|
@@ -1473,7 +1810,7 @@ export class Journal {
|
|
|
1473
1810
|
const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
|
|
1474
1811
|
return preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
|
|
1475
1812
|
})()
|
|
1476
|
-
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(priorEvents).has(rowTaskId)
|
|
1813
|
+
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
|
|
1477
1814
|
? {
|
|
1478
1815
|
...inputData,
|
|
1479
1816
|
upheldFeedbackRestoredFor: rowTaskId,
|
|
@@ -1540,12 +1877,27 @@ export class Journal {
|
|
|
1540
1877
|
read() {
|
|
1541
1878
|
return readJsonl(this.journalPath);
|
|
1542
1879
|
}
|
|
1880
|
+
/** Parsed rows paired with their physical 1-based journal lines (OBS-1178 bindings name lines). */
|
|
1881
|
+
readSourced() {
|
|
1882
|
+
const rows = readJsonlSource(this.journalPath);
|
|
1883
|
+
return { events: rows.map((row) => row.raw), lines: rows.map((row) => row.sourceIndex + 1) };
|
|
1884
|
+
}
|
|
1885
|
+
/** OBS-1178: the binding of a task's newest park (or `task-failed`) row — what a decision on it names. */
|
|
1886
|
+
newestBinding(taskId, event = "task-human") {
|
|
1887
|
+
const { events, lines } = this.readSourced();
|
|
1888
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1889
|
+
const e = events[i];
|
|
1890
|
+
if (e.event === event && e.taskId === taskId)
|
|
1891
|
+
return typeof e.ts === "string" ? { line: lines[i], ts: e.ts } : undefined;
|
|
1892
|
+
}
|
|
1893
|
+
return undefined;
|
|
1894
|
+
}
|
|
1543
1895
|
readTracked() {
|
|
1544
1896
|
return trackJournalRows(this.runId, readJsonlSource(this.journalPath));
|
|
1545
1897
|
}
|
|
1546
1898
|
replayStatuses() {
|
|
1547
1899
|
const s = new Map();
|
|
1548
|
-
for (const e of this.read()) {
|
|
1900
|
+
for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound decision released nothing
|
|
1549
1901
|
if (!e.taskId)
|
|
1550
1902
|
continue;
|
|
1551
1903
|
if (e.event === "task-dispatch")
|
|
@@ -1608,7 +1960,7 @@ export class Journal {
|
|
|
1608
1960
|
}
|
|
1609
1961
|
replayResumeState() {
|
|
1610
1962
|
const m = new Map();
|
|
1611
|
-
const events = this.read();
|
|
1963
|
+
const events = effectiveEvents(this.read()); // OBS-1178: a refused or unsound decision funds no budget
|
|
1612
1964
|
// Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
|
|
1613
1965
|
// In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
|
|
1614
1966
|
const activeUpheldFeedback = upheldFeedbackByTask(events);
|
|
@@ -1639,6 +1991,8 @@ export class Journal {
|
|
|
1639
1991
|
const added = !st.tried.includes(key);
|
|
1640
1992
|
if (added)
|
|
1641
1993
|
st.tried.push(key);
|
|
1994
|
+
if (st.escalated)
|
|
1995
|
+
delete st.escalated[key];
|
|
1642
1996
|
lastDispatch.set(e.taskId, {
|
|
1643
1997
|
...(added ? { addedKey: key } : {}),
|
|
1644
1998
|
prevAssignment: st.lastAssignment,
|
|
@@ -1673,6 +2027,32 @@ export class Journal {
|
|
|
1673
2027
|
lastDispatch.delete(e.taskId);
|
|
1674
2028
|
}
|
|
1675
2029
|
}
|
|
2030
|
+
else if (e.event === "capacity-requeue" || BOOTSTRAP_DEATH.has(e.event)) {
|
|
2031
|
+
// OBS-1161: a capacity requeue is free — the daemon re-dispatches the SAME attempt on the SAME
|
|
2032
|
+
// seat — so the dispatch it closes must not count, or a resume bills every busy-seat wait
|
|
2033
|
+
// against MAX_ATTEMPTS. Only the attempt rewinds: the seat stays tried and in force (busy, not
|
|
2034
|
+
// burned). Idempotent by the same rule as the scope rewind: only the OUTSTANDING dispatch.
|
|
2035
|
+
// OBS-1169: a bootstrap death bought no worker turn either, retried in place or failed over.
|
|
2036
|
+
const st = m.get(e.taskId);
|
|
2037
|
+
if (st && lastDispatch.delete(e.taskId))
|
|
2038
|
+
st.attempts = Math.max(0, st.attempts - 1);
|
|
2039
|
+
if (st && e.event === "bootstrap-failover") {
|
|
2040
|
+
// OBS-1169: the failover's routing disposition outlives the process too — its escalated
|
|
2041
|
+
// siblings stay excluded with their reason, and a crash before the destination's dispatch
|
|
2042
|
+
// resumes ON that destination instead of relaunching the exhausted source channel.
|
|
2043
|
+
const reason = typeof e.data.reason === "string" ? e.data.reason : "vendor escalated";
|
|
2044
|
+
for (const k of Array.isArray(e.data.escalated) ? e.data.escalated : []) {
|
|
2045
|
+
if (typeof k === "string" && !st.tried.includes(k))
|
|
2046
|
+
st.escalated = { ...st.escalated, [k]: reason };
|
|
2047
|
+
}
|
|
2048
|
+
// The whole ADAPTER of `from` (a channel key, `adapter:model`) is excluded, sibling or none.
|
|
2049
|
+
const adapter = typeof e.data.from === "string" ? e.data.from.split(":")[0] : undefined;
|
|
2050
|
+
if (adapter && !st.escalatedAdapters?.includes(adapter))
|
|
2051
|
+
st.escalatedAdapters = [...(st.escalatedAdapters ?? []), adapter];
|
|
2052
|
+
const to = DispatchAssignmentSchema.safeParse(e.data.toAssignment);
|
|
2053
|
+
st.lastAssignment = to.success ? to.data : undefined;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
1676
2056
|
else if (e.event === "consult-verdict" && e.data.action === "reroute") {
|
|
1677
2057
|
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
1678
2058
|
pendingReroute.add(e.taskId);
|
|
@@ -1690,6 +2070,8 @@ export class Journal {
|
|
|
1690
2070
|
st.attempts = 0;
|
|
1691
2071
|
st.tried = [];
|
|
1692
2072
|
st.lastAssignment = undefined;
|
|
2073
|
+
delete st.escalated;
|
|
2074
|
+
delete st.escalatedAdapters;
|
|
1693
2075
|
}
|
|
1694
2076
|
}
|
|
1695
2077
|
else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
|
|
@@ -1743,7 +2125,7 @@ export class Journal {
|
|
|
1743
2125
|
const reviewSubjects = new Map();
|
|
1744
2126
|
const subjects = new Map();
|
|
1745
2127
|
const reviewWaivers = new Map();
|
|
1746
|
-
for (const e of this.read()) {
|
|
2128
|
+
for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound waive satisfies nothing
|
|
1747
2129
|
if (!e.taskId)
|
|
1748
2130
|
continue;
|
|
1749
2131
|
if (e.event === "task-dispatch") {
|
|
@@ -1803,7 +2185,7 @@ export class Journal {
|
|
|
1803
2185
|
// exclusively to replaySatisfiedGates(), whose typed release-marker contract above is unchanged.
|
|
1804
2186
|
replayCurrentAttemptGateResults() {
|
|
1805
2187
|
const replay = new Map();
|
|
1806
|
-
for (const e of this.read()) {
|
|
2188
|
+
for (const e of effectiveEvents(this.read())) {
|
|
1807
2189
|
if (!e.taskId)
|
|
1808
2190
|
continue;
|
|
1809
2191
|
if (e.event === "task-dispatch") {
|