faberun 0.3.0
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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry in place (TECH-SPEC lean v0.3, rule 4 and decision 9.3).
|
|
3
|
+
*
|
|
4
|
+
* A retry is the same run and the same node: attempt plus one, in the same run
|
|
5
|
+
* directory, with the failure attached. Instructions are frozen per run, so the
|
|
6
|
+
* packet never changes; what changes is the prompt, which carries a bounded
|
|
7
|
+
* `Previous attempt` section for the worker and for the judge.
|
|
8
|
+
*
|
|
9
|
+
* Resume adopts completed work first — an orphaned running node as before, and
|
|
10
|
+
* a blocking review that never produced a verdict (`judge_unavailable`) is
|
|
11
|
+
* re-judged from the preserved worker result instead of being reset or
|
|
12
|
+
* re-dispatched to a worker — and only then re-dispatches ordinary failures.
|
|
13
|
+
* Two stop boundaries are crossed by an explicit flag alone: a node blocked
|
|
14
|
+
* with `unknown_effect_reconciled` needs `--reconcile <node>`, and an
|
|
15
|
+
* exhausted run budget needs `--max-input-tokens <n>`.
|
|
16
|
+
*/
|
|
17
|
+
import { Buffer } from "node:buffer";
|
|
18
|
+
|
|
19
|
+
/** Heading of the section appended to a retried attempt's worker and judge prompt. */
|
|
20
|
+
const PREVIOUS_ATTEMPT_HEADING = "Previous attempt";
|
|
21
|
+
|
|
22
|
+
/** Hard ceiling for the whole `Previous attempt` section, in bytes. */
|
|
23
|
+
const PREVIOUS_ATTEMPT_MAX_BYTES = 8 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Heading under which an operator's answer is rendered inside the section. */
|
|
26
|
+
const OPERATOR_ANSWER_HEADING = "Operator answer";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Node statuses an ordinary resume re-dispatches as attempt plus one.
|
|
30
|
+
* `blocked` is deliberately absent: only its two error codes have a retry
|
|
31
|
+
* meaning, and `context_missing` stays terminal.
|
|
32
|
+
*/
|
|
33
|
+
const RETRYABLE_STATUSES = new Set(["failed", "stalled", "exhausted", "canceled"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{status?: string, error?: {code?: string}|null}} state
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isJudgeUnavailable(state) {
|
|
40
|
+
return state.status === "blocked" && state.error?.code === "judge_unavailable";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{status?: string, error?: {code?: string}|null}} state
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isDependencyFailed(state) {
|
|
48
|
+
return state.status === "blocked" && state.error?.code === "dependency_failed";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{status?: string, error?: {code?: string}|null}} state
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isUnknownEffectStop(state) {
|
|
56
|
+
return state.status === "blocked" && state.error?.code === "unknown_effect_reconciled";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{status?: string, error?: {code?: string}|null}} state
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isRetryableFailure(state) {
|
|
64
|
+
return RETRYABLE_STATUSES.has(/** @type {string} */ (state.status)) || isDependencyFailed(state);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The node a `--node` flag names plus every node that transitively depends on
|
|
69
|
+
* it: a retried dependency re-opens its dependants, so they are part of the
|
|
70
|
+
* same retry.
|
|
71
|
+
*
|
|
72
|
+
* @param {{id: string, dependsOn?: string[]}[]} planNodes
|
|
73
|
+
* @param {string} nodeId
|
|
74
|
+
* @returns {Set<string>}
|
|
75
|
+
*/
|
|
76
|
+
function retryTargets(planNodes, nodeId) {
|
|
77
|
+
const targets = new Set([nodeId]);
|
|
78
|
+
let grew = true;
|
|
79
|
+
while (grew) {
|
|
80
|
+
grew = false;
|
|
81
|
+
for (const node of planNodes) {
|
|
82
|
+
if (targets.has(node.id)) continue;
|
|
83
|
+
if ((node.dependsOn ?? []).some((dependency) => targets.has(dependency))) {
|
|
84
|
+
targets.add(node.id);
|
|
85
|
+
grew = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return targets;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The earliest reset instant recorded among a node's tier-exhaustion
|
|
94
|
+
* candidates, or null when no candidate carries a parseable `exhaustedUntil`.
|
|
95
|
+
* Shared by `planResumeRetry`'s hold decision and `supervise`'s waiting state
|
|
96
|
+
* so the two can never disagree about when an exhausted node is due.
|
|
97
|
+
*
|
|
98
|
+
* @param {import("../contract/index.mjs").NodeSnapshot} state
|
|
99
|
+
* @returns {number|null} epoch milliseconds, or null when nothing is parseable
|
|
100
|
+
*/
|
|
101
|
+
export function earliestTierReset(state) {
|
|
102
|
+
const candidates = state.routing?.tierExhaustion?.candidates ?? [];
|
|
103
|
+
const instants = candidates.map((candidate) => Date.parse(candidate.exhaustedUntil ?? "")).filter(Number.isFinite);
|
|
104
|
+
return instants.length ? Math.min(...instants) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The longest a tier-exhausted node may be held waiting for its tier to
|
|
109
|
+
* recover before the controller stops waiting. A generation with no recorded
|
|
110
|
+
* reset instant would otherwise hold forever, and an announced instant can be
|
|
111
|
+
* arbitrarily far away; both are bounded here. After the cap the named
|
|
112
|
+
* post-cap transition is a re-dispatch on the current runtime (`retry`, or
|
|
113
|
+
* `rejudge` for a judge phase), with attention raised so the operator sees that
|
|
114
|
+
* the wait was abandoned rather than honoured.
|
|
115
|
+
*/
|
|
116
|
+
export const TIER_EXHAUSTION_HOLD_CAP_MS = 24 * 60 * 60 * 1000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The reason recorded when `TIER_EXHAUSTION_HOLD_CAP_MS` is exceeded. It names
|
|
120
|
+
* the post-cap transition explicitly so the operator-facing text and the
|
|
121
|
+
* classification cannot drift.
|
|
122
|
+
*/
|
|
123
|
+
export const TIER_EXHAUSTION_CAP_REASON = "tier-exhaustion hold cap exceeded; post-cap transition re-dispatches on the current runtime";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How long a node has already been held on its tier exhaustion. The block
|
|
127
|
+
* transition wrote `updatedAt`; an unparseable or absent stamp is treated as
|
|
128
|
+
* "not yet held" so a snapshot that predates the timestamp cannot be pushed
|
|
129
|
+
* over the cap on first read.
|
|
130
|
+
*
|
|
131
|
+
* @param {import("../contract/index.mjs").NodeSnapshot} state
|
|
132
|
+
* @returns {number} elapsed milliseconds, never negative
|
|
133
|
+
*/
|
|
134
|
+
export function tierExhaustionHeldMs(state) {
|
|
135
|
+
const since = Date.parse(state.updatedAt ?? state.startedAt ?? "");
|
|
136
|
+
return Number.isFinite(since) ? Math.max(0, Date.now() - since) : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decide, per node, what a resume does with it. Everything classified
|
|
141
|
+
* `recover` keeps today's recovery behaviour (orphan adoption, re-judge of
|
|
142
|
+
* finished work, pending re-dispatch). `hold` leaves the persisted state
|
|
143
|
+
* exactly as it is and reports attention: resetting it would either replay an
|
|
144
|
+
* unknown effect or re-dispatch work the operator did not ask for.
|
|
145
|
+
*
|
|
146
|
+
* @param {{nodes: {id: string, dependsOn?: string[]}[]}} contract
|
|
147
|
+
* @param {Map<string, import("../contract/index.mjs").NodeSnapshot>} states
|
|
148
|
+
* @param {{node?: string, reconcile?: string, answer?: string}} [options]
|
|
149
|
+
* @returns {{actions: Map<string, "recover"|"retry"|"rejudge"|"hold">, attention: {id: string, reason: string}[], targets: Set<string>|null}}
|
|
150
|
+
*/
|
|
151
|
+
export function planResumeRetry(contract, states, options = {}) {
|
|
152
|
+
// `--answer <node>` narrows the retry exactly like `--node <node>`: the named
|
|
153
|
+
// node plus every node that transitively depends on it.
|
|
154
|
+
const targetNode = options.node ?? options.answer;
|
|
155
|
+
const targets = targetNode ? retryTargets(contract.nodes, targetNode) : null;
|
|
156
|
+
/** @type {Map<string, "recover"|"retry"|"rejudge"|"hold">} */
|
|
157
|
+
const actions = new Map();
|
|
158
|
+
/** @type {{id: string, reason: string}[]} */
|
|
159
|
+
const attention = [];
|
|
160
|
+
/** @type {(node: string, state: import("../contract/index.mjs").NodeSnapshot) => "recover"|"retry"|"rejudge"|"hold"} */
|
|
161
|
+
const classify = (node, state) => {
|
|
162
|
+
if (isJudgeUnavailable(state)) {
|
|
163
|
+
// Adoption before retry: the worker result and the verification records
|
|
164
|
+
// are on disk, so only the arbitration is missing.
|
|
165
|
+
if (targets && !targets.has(node)) return hold(node, "its unresolved review is outside this retry");
|
|
166
|
+
return "rejudge";
|
|
167
|
+
}
|
|
168
|
+
if (isUnknownEffectStop(state)) {
|
|
169
|
+
// Stop boundary: only an explicit acknowledgement re-dispatches it.
|
|
170
|
+
return options.reconcile === node
|
|
171
|
+
? "retry"
|
|
172
|
+
: hold(node, "an unknown workspace effect needs an explicit `resume --reconcile`");
|
|
173
|
+
}
|
|
174
|
+
if (state.status === "blocked" && state.error?.code === "context_missing") {
|
|
175
|
+
// A node the operator answered is dispatchable again; every other
|
|
176
|
+
// `context_missing` node keeps its terminal boundary.
|
|
177
|
+
return options.answer === node
|
|
178
|
+
? "retry"
|
|
179
|
+
: "recover";
|
|
180
|
+
}
|
|
181
|
+
if (state.status === "blocked" && state.error?.code === "runtime_tier_exhausted") {
|
|
182
|
+
// A node outside an explicit `--node` retry is held before anything else,
|
|
183
|
+
// even past the cap: an operator asking for one node must not have the
|
|
184
|
+
// cap re-dispatch a different one.
|
|
185
|
+
if (targets && !targets.has(node)) return hold(node, "it is outside the `--node` retry");
|
|
186
|
+
// The cap bounds the wait: once exceeded, the named post-cap transition
|
|
187
|
+
// re-dispatches on the current runtime instead of holding forever.
|
|
188
|
+
if (tierExhaustionHeldMs(state) >= TIER_EXHAUSTION_HOLD_CAP_MS) {
|
|
189
|
+
attention.push({ id: node, reason: TIER_EXHAUSTION_CAP_REASON });
|
|
190
|
+
return state.phase === "judge" ? "rejudge" : "retry";
|
|
191
|
+
}
|
|
192
|
+
// Hold until the earliest reset the exhausted candidates announced; a
|
|
193
|
+
// generation with no parseable instant holds until the cap rather than
|
|
194
|
+
// inventing a deadline. The final candidate is already in the evidence
|
|
195
|
+
// list, so this reads Phase 1a's record, never `state.error.exhaustedUntil`.
|
|
196
|
+
const earliest = earliestTierReset(state);
|
|
197
|
+
if (earliest === null) return hold(node, "no recorded reset time for any exhausted candidate");
|
|
198
|
+
if (Date.now() < earliest) return hold(node, `earliest recorded reset is ${new Date(earliest).toISOString()}`);
|
|
199
|
+
// A judge-tier exhaustion keeps its order: rejudge transitions to the
|
|
200
|
+
// judge phase and preserves the accepted worker result, where a retry
|
|
201
|
+
// would discard it by re-dispatching a worker.
|
|
202
|
+
return state.phase === "judge" ? "rejudge" : "retry";
|
|
203
|
+
}
|
|
204
|
+
if (!isRetryableFailure(state)) return "recover";
|
|
205
|
+
if (targets && !targets.has(node)) return hold(node, "it is outside the `--node` retry");
|
|
206
|
+
return "retry";
|
|
207
|
+
};
|
|
208
|
+
/** @type {(id: string, reason: string) => "hold"} */
|
|
209
|
+
const hold = (id, reason) => {
|
|
210
|
+
attention.push({ id, reason });
|
|
211
|
+
return "hold";
|
|
212
|
+
};
|
|
213
|
+
for (const node of contract.nodes) {
|
|
214
|
+
const state = states.get(node.id);
|
|
215
|
+
if (!state) continue;
|
|
216
|
+
actions.set(node.id, classify(node.id, state));
|
|
217
|
+
}
|
|
218
|
+
// A node blocked on a failed dependency is retried only when the dependency
|
|
219
|
+
// it waited on is retried too; otherwise it keeps its boundary.
|
|
220
|
+
for (const node of contract.nodes) {
|
|
221
|
+
const state = states.get(node.id);
|
|
222
|
+
if (!state || !isDependencyFailed(state) || actions.get(node.id) !== "retry") continue;
|
|
223
|
+
const waitedOnRetried = (state.blockedBy ?? []).every((id) => actions.get(id) === "retry" || actions.get(id) === "rejudge" || states.get(id)?.status === "done");
|
|
224
|
+
if (!waitedOnRetried) {
|
|
225
|
+
actions.set(node.id, hold(node.id, "the dependency it waited on is not part of this retry"));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { actions, attention, targets };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The most recent operator-answer override's text, or null. A second
|
|
233
|
+
* `--answer` appends rather than replaces, so the newest record is what
|
|
234
|
+
* surfaces; the older answers stay in `executionOverrides` untouched.
|
|
235
|
+
*
|
|
236
|
+
* @param {import("../contract/index.mjs").NodeSnapshot} state
|
|
237
|
+
* @returns {string|null}
|
|
238
|
+
*/
|
|
239
|
+
function mostRecentOperatorAnswer(state) {
|
|
240
|
+
const overrides = state.executionOverrides ?? [];
|
|
241
|
+
for (let index = overrides.length - 1; index >= 0; index -= 1) {
|
|
242
|
+
const record = /** @type {Record<string, unknown>} */ (overrides[index]);
|
|
243
|
+
if (record.kind === "operator-answer") {
|
|
244
|
+
return typeof record.text === "string" ? record.text : null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The bounded `Previous attempt` section for a node about to be re-dispatched:
|
|
252
|
+
* the error code and message, the most recent operator answer (when one was
|
|
253
|
+
* supplied), the judge findings, the scope findings, and the failing
|
|
254
|
+
* verification commands with a bounded output tail.
|
|
255
|
+
*
|
|
256
|
+
* @param {import("../contract/index.mjs").NodeSnapshot} state
|
|
257
|
+
* @returns {string|null} null when the node carries no failure evidence
|
|
258
|
+
*/
|
|
259
|
+
export function renderPreviousAttemptSection(state) {
|
|
260
|
+
/** @type {string[]} */
|
|
261
|
+
const parts = [];
|
|
262
|
+
const attempt = state.attempt ?? 0;
|
|
263
|
+
// A node that never started an attempt has no failure to carry: its blocked
|
|
264
|
+
// dependency is already visible in the graph.
|
|
265
|
+
if (attempt === 0) return null;
|
|
266
|
+
parts.push(`Attempt ${attempt} failed; this is attempt ${attempt + 1}. The packet is unchanged.`);
|
|
267
|
+
if (state.error?.code || state.error?.message) {
|
|
268
|
+
parts.push(`Error: ${state.error?.code ?? "unknown"}${state.error?.message ? ` — ${bounded(state.error.message, 1024)}` : ""}`);
|
|
269
|
+
}
|
|
270
|
+
const answer = mostRecentOperatorAnswer(state);
|
|
271
|
+
if (answer !== null) {
|
|
272
|
+
parts.push(`${OPERATOR_ANSWER_HEADING}:`);
|
|
273
|
+
parts.push(bounded(answer, 1024));
|
|
274
|
+
}
|
|
275
|
+
const findings = state.gate?.findings ?? [];
|
|
276
|
+
if (findings.length) {
|
|
277
|
+
parts.push(`Judge findings (verdict ${state.gate?.verdict ?? "unknown"}, maxSeverity ${state.gate?.maxSeverity ?? "none"}):`);
|
|
278
|
+
for (const finding of findings) {
|
|
279
|
+
parts.push(`- [${finding.severity}] ${bounded(finding.description, 1024)}`);
|
|
280
|
+
if (finding.evidence) parts.push(` Evidence: ${bounded(finding.evidence, 1024)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const unexpected = state.scopeFindings?.unexpectedPaths ?? [];
|
|
284
|
+
if (unexpected.length) parts.push(`Scope findings (paths outside the declared boundary): ${unexpected.map((path) => bounded(path, 256)).join(", ")}`);
|
|
285
|
+
const failing = (state.verification?.commands ?? []).filter((command) => !command.passed);
|
|
286
|
+
if (failing.length) {
|
|
287
|
+
parts.push("Failing verification:");
|
|
288
|
+
for (const command of failing) {
|
|
289
|
+
parts.push(`- ${command.argv.join(" ")}`);
|
|
290
|
+
const last = command.attempts?.at(-1);
|
|
291
|
+
if (last?.stdout) parts.push(` stdout tail: ${indent(last.stdout)}`);
|
|
292
|
+
if (last?.stderr) parts.push(` stderr tail: ${indent(last.stderr)}`);
|
|
293
|
+
if (typeof last?.exitCode === "number" && last.exitCode !== 0) parts.push(` exit code: ${last.exitCode}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (parts.length === 1) return null;
|
|
297
|
+
return boundSection([`## ${PREVIOUS_ATTEMPT_HEADING}`, "", ...parts].join("\n"));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Append the section to a regenerated prompt. A prompt that already carries it
|
|
302
|
+
* (a gate revision re-rendered after a resume) is left alone.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} prompt
|
|
305
|
+
* @param {string|undefined} section
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
export function appendPreviousAttempt(prompt, section) {
|
|
309
|
+
if (!section || prompt.includes(`## ${PREVIOUS_ATTEMPT_HEADING}`)) return prompt;
|
|
310
|
+
return `${prompt}\n\n${section}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {string} text
|
|
315
|
+
* @returns {string} the text bounded to the section ceiling, with a marker
|
|
316
|
+
*/
|
|
317
|
+
function boundSection(text) {
|
|
318
|
+
if (Buffer.byteLength(text, "utf8") <= PREVIOUS_ATTEMPT_MAX_BYTES) return text;
|
|
319
|
+
const marker = "\n… (previous attempt section truncated)";
|
|
320
|
+
const room = PREVIOUS_ATTEMPT_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
|
|
321
|
+
const cut = Buffer.from(text, "utf8").subarray(0, room).toString("utf8");
|
|
322
|
+
return `${cut}${marker}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** @param {string} text @param {number} maxBytes @returns {string} */
|
|
326
|
+
function bounded(text, maxBytes) {
|
|
327
|
+
const bytes = Buffer.from(String(text ?? ""), "utf8");
|
|
328
|
+
return bytes.length <= maxBytes ? String(text) : `${bytes.subarray(0, maxBytes - 1).toString("utf8")}…`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Collapse a persisted output tail onto one indented line. @param {string} text @returns {string} */
|
|
332
|
+
function indent(text) {
|
|
333
|
+
return bounded(String(text ?? "").replace(/\s+/gu, " ").trim(), 1024);
|
|
334
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judge round arbitration: interpret a returned verdict, decide re-ask versus
|
|
3
|
+
* settlement, and route what `startJudge` decided.
|
|
4
|
+
*
|
|
5
|
+
* `engine/dispatch.mjs` launches the judge invocation and hands the result
|
|
6
|
+
* here; this module never starts a provider invocation itself. The rejection
|
|
7
|
+
* paths themselves live in `engine/settle.mjs`, because the control loop
|
|
8
|
+
* settles too and having them here made the two import each other.
|
|
9
|
+
*/import { parseJudge } from "./prompts.mjs";
|
|
10
|
+
import {
|
|
11
|
+
clearJudgeReask,
|
|
12
|
+
judgeReaskOutstanding,
|
|
13
|
+
markJudgeReask,
|
|
14
|
+
uncitedRejection,
|
|
15
|
+
} from "./judge-gate.mjs";
|
|
16
|
+
import {
|
|
17
|
+
JUDGE_UNAVAILABLE_CODE,
|
|
18
|
+
UNCITED_REJECTION_REASON,
|
|
19
|
+
invalidJudgeVerdict,
|
|
20
|
+
reviewMode,
|
|
21
|
+
} from "../contract/review-modes.mjs";
|
|
22
|
+
|
|
23
|
+
import { errorMessage, excerpt } from "../util.mjs";
|
|
24
|
+
import { appendTransitionEvent, transition } from "./state.mjs";
|
|
25
|
+
import { startJudge } from "./dispatch.mjs";
|
|
26
|
+
import { applyRejection, raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
27
|
+
|
|
28
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
29
|
+
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
30
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
31
|
+
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
32
|
+
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
33
|
+
/** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
|
|
34
|
+
/** @typedef {import("./lifecycle.mjs").Job} Job */
|
|
35
|
+
|
|
36
|
+
/** A failed judge envelope gets one bounded re-dispatch, then judge_unavailable. */
|
|
37
|
+
export const JUDGE_MAX_FAILURES = 2;
|
|
38
|
+
|
|
39
|
+
/** Attention code raised when an advisory review completes without a verdict. */
|
|
40
|
+
const INVALID_JUDGE_OUTPUT_CODE = "invalid_judge_output";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Settle a judge round that produced no usable verdict. The first defect earns
|
|
44
|
+
* the one bounded re-ask on the node's own bound; once it is spent the review
|
|
45
|
+
* mode decides, and blocking review never degrades into a pass.
|
|
46
|
+
*
|
|
47
|
+
* @param {ValidatedContract} contract
|
|
48
|
+
* @param {ValidatedNode} node
|
|
49
|
+
* @param {NodeSnapshot} state
|
|
50
|
+
* @param {string} runDir
|
|
51
|
+
* @param {Map<string, Job>|null} running
|
|
52
|
+
* @param {LockHandle} lock
|
|
53
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
54
|
+
* @param {string} campaignPath
|
|
55
|
+
* @param {string} reason
|
|
56
|
+
*/
|
|
57
|
+
export async function applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, reason) {
|
|
58
|
+
if (judgeReaskOutstanding(state)) {
|
|
59
|
+
if (reviewMode(node.gate) === "advisory") {
|
|
60
|
+
// The deterministic verification passed, so the review cannot fail the
|
|
61
|
+
// node; completing with the defect recorded keeps it visible instead of
|
|
62
|
+
// silently discarding the review.
|
|
63
|
+
await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, invalidJudgeVerdict(reason));
|
|
64
|
+
await raiseNodeAttention(campaignPath, runDir, state, INVALID_JUDGE_OUTPUT_CODE);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Nothing about the attempt is rewritten: the accepted worker result, the
|
|
68
|
+
// verification records and the gate state stay exactly as a node awaiting
|
|
69
|
+
// its judge leaves them, so a retry in place re-judges instead of re-running.
|
|
70
|
+
transition(runDir, state, "blocked", {
|
|
71
|
+
phase: "judge",
|
|
72
|
+
result: state.result,
|
|
73
|
+
usage: state.usage,
|
|
74
|
+
error: { code: JUDGE_UNAVAILABLE_CODE, message: excerpt(reason) ?? "judge unavailable" },
|
|
75
|
+
}, lock);
|
|
76
|
+
await raiseNodeAttention(campaignPath, runDir, state, JUDGE_UNAVAILABLE_CODE);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// The bound rides on the node: the next write — the recovered pending
|
|
80
|
+
// judge below or the re-ask dispatch's own invocation — persists it with
|
|
81
|
+
// the transition it belongs to, leaving no gap either way.
|
|
82
|
+
markJudgeReask(state, reason);
|
|
83
|
+
if (!running) {
|
|
84
|
+
// A verdict recovered after controller loss keeps its durable bound:
|
|
85
|
+
// the drive loop dispatches the one remaining bounded re-ask.
|
|
86
|
+
transition(runDir, state, "pending", { phase: "judge", gate: null, result: state.result, error: null, blockedBy: [] }, lock);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
90
|
+
contract, node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Settle an advisory gate: the verdict is recorded with its findings and the
|
|
95
|
+
* node completes, because the deterministic verification already passed. A
|
|
96
|
+
* verdict that is not a clean pass appends the `gate.advisory` event the run
|
|
97
|
+
* journal keeps for review outcomes.
|
|
98
|
+
*
|
|
99
|
+
* @param {ValidatedContract} contract
|
|
100
|
+
* @param {ValidatedNode} node
|
|
101
|
+
* @param {NodeSnapshot} state
|
|
102
|
+
* @param {string} runDir
|
|
103
|
+
* @param {LockHandle} lock
|
|
104
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
105
|
+
* @param {string} campaignPath
|
|
106
|
+
* @param {JudgeVerdict} verdict
|
|
107
|
+
*/
|
|
108
|
+
async function settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict) {
|
|
109
|
+
clearJudgeReask(state);
|
|
110
|
+
state.gate = verdict;
|
|
111
|
+
if (verdict.verdict !== "pass") {
|
|
112
|
+
appendTransitionEvent(runDir, state, state.status, state.status, {
|
|
113
|
+
type: "gate.advisory",
|
|
114
|
+
verdict: verdict.verdict,
|
|
115
|
+
summary: verdict.summary,
|
|
116
|
+
}, lock);
|
|
117
|
+
}
|
|
118
|
+
await settleDone(contract, node, state, runDir, lock, states, campaignPath, { phase: "complete", gate: verdict });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Settle a judge whose provider failed its bounded re-dispatches: blocking review blocks with the work preserved; advisory review completes with the defect recorded. @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {LockHandle} lock @param {Map<string, NodeSnapshot>} states @param {string} campaignPath @param {string} providerMessage */
|
|
122
|
+
export async function settleUnavailableJudge(contract, node, state, runDir, lock, states, campaignPath, providerMessage) {
|
|
123
|
+
if (reviewMode(node.gate) === "advisory") {
|
|
124
|
+
await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, invalidJudgeVerdict(providerMessage));
|
|
125
|
+
await raiseNodeAttention(campaignPath, runDir, state, INVALID_JUDGE_OUTPUT_CODE);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
transition(runDir, state, "blocked", {
|
|
129
|
+
phase: "judge",
|
|
130
|
+
result: state.result,
|
|
131
|
+
usage: state.usage,
|
|
132
|
+
error: { code: JUDGE_UNAVAILABLE_CODE, message: excerpt(providerMessage) ?? "judge unavailable" },
|
|
133
|
+
}, lock);
|
|
134
|
+
await raiseNodeAttention(campaignPath, runDir, state, JUDGE_UNAVAILABLE_CODE);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Apply a judge verdict: pass settles done; a rejection citing no judgment item id is a judge protocol failure — one durable bounded re-ask, then blocked judge_protocol attention — and never consumes a revision, at any severity. Under advisory review a fail verdict is recorded and the node still completes. @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {unknown} result @param {string} runDir @param {LockHandle} lock @param {Map<string, Job>|null} running @param {Map<string, NodeSnapshot>} states @param {string} campaignPath */
|
|
138
|
+
export async function applyJudgeResult(contract, node, state, result, runDir, lock, running, states, campaignPath) {
|
|
139
|
+
/** @type {JudgeVerdict} */
|
|
140
|
+
let verdict;
|
|
141
|
+
try {
|
|
142
|
+
verdict = parseJudge(String(result ?? ""));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, errorMessage(error));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
state.gate = verdict;
|
|
148
|
+
state.judgeFailures = 0;
|
|
149
|
+
const advisory = reviewMode(node.gate) === "advisory";
|
|
150
|
+
const protocolFailure = verdict.verdict === "fail" && uncitedRejection(verdict, node);
|
|
151
|
+
if (protocolFailure && judgeReaskOutstanding(state)) {
|
|
152
|
+
if (advisory) {
|
|
153
|
+
await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict);
|
|
154
|
+
await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
transition(runDir, state, "blocked", {
|
|
158
|
+
phase: "judge",
|
|
159
|
+
gate: verdict,
|
|
160
|
+
result: state.result,
|
|
161
|
+
error: { code: "judge_protocol", message: "judge rejection cited no Definition of Done item id after the bounded re-ask" },
|
|
162
|
+
}, lock);
|
|
163
|
+
await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (protocolFailure) {
|
|
167
|
+
markJudgeReask(state, UNCITED_REJECTION_REASON);
|
|
168
|
+
if (!running) {
|
|
169
|
+
// A verdict recovered after controller loss keeps its durable bound:
|
|
170
|
+
// the drive loop dispatches the one remaining bounded re-ask.
|
|
171
|
+
transition(runDir, state, "pending", { phase: "judge", gate: null, result: state.result, error: null, blockedBy: [] }, lock);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
175
|
+
contract, node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
clearJudgeReask(state);
|
|
179
|
+
if (advisory) {
|
|
180
|
+
await settleAdvisoryReview(contract, node, state, runDir, lock, states, campaignPath, verdict);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const shouldFail = verdict.verdict === "fail" && verdict.maxSeverity !== "none"
|
|
184
|
+
&& (node.gate.failOn ?? ["critical"]).includes(verdict.maxSeverity);
|
|
185
|
+
if (!shouldFail) {
|
|
186
|
+
await settleDone(contract, node, state, runDir, lock, states, campaignPath, { phase: "complete", gate: verdict });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
applyRejection(contract, node, state, runDir, running, lock, states, campaignPath, verdict, {
|
|
190
|
+
code: "revision_cap",
|
|
191
|
+
label: "gate",
|
|
192
|
+
phase: "judge",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Act on what a judge round decided. `startJudge` used to do this itself, which
|
|
198
|
+
* made dispatch depend on both review policy and settlement; the five callers
|
|
199
|
+
* all reach both already, so the decision comes back to them and lands here.
|
|
200
|
+
*
|
|
201
|
+
* @param {import("./dispatch.mjs").JudgeRound} round
|
|
202
|
+
* @param {ValidatedContract} contract
|
|
203
|
+
* @param {ValidatedNode} node
|
|
204
|
+
* @param {NodeSnapshot} state
|
|
205
|
+
* @param {string} runDir
|
|
206
|
+
* @param {Map<string, Job>} running
|
|
207
|
+
* @param {LockHandle} lock
|
|
208
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
209
|
+
* @param {string} campaignPath
|
|
210
|
+
* @param {unknown} workerResult
|
|
211
|
+
* @returns {Promise<void>}
|
|
212
|
+
*/
|
|
213
|
+
export async function applyJudgeRound(round, contract, node, state, runDir, running, lock, states, campaignPath, workerResult) {
|
|
214
|
+
if (round.kind === "rejected") {
|
|
215
|
+
applyRejection(contract, node, state, runDir, running, lock, states, campaignPath, round.verdict, {
|
|
216
|
+
code: "mechanical_gate_failed",
|
|
217
|
+
label: "mechanical-gate",
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (round.kind === "settle") {
|
|
222
|
+
await settleDone(contract, node, state, runDir, lock, states, campaignPath, {
|
|
223
|
+
phase: "complete",
|
|
224
|
+
result: workerResult,
|
|
225
|
+
gate: round.gate,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|