@sabaiway/agent-workflow-kit 5.3.0 → 5.5.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/CHANGELOG.md +138 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +53 -5
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +622 -30
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +731 -3
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +15 -10
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +16 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/coverage-check.md +1 -1
- package/references/modes/dispatch.md +29 -0
- package/references/modes/gates.md +7 -2
- package/references/modes/receipt-deadline.md +3 -3
- package/references/modes/recommendations.md +3 -1
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +5 -1
- package/references/scripts/migrate-gates.mjs +102 -10
- package/references/scripts/migrate-gates.test.mjs +37 -0
- package/tools/commands.mjs +7 -0
- package/tools/core-evidence.mjs +79 -5
- package/tools/coverage-check.mjs +23 -7
- package/tools/coverage-producer.mjs +68 -0
- package/tools/coverage-state.mjs +24 -0
- package/tools/declared-paths.mjs +32 -0
- package/tools/detect-backends.mjs +5 -4
- package/tools/dispatch-record.mjs +10 -3
- package/tools/dispatch-store.mjs +392 -0
- package/tools/dispatch.mjs +1779 -0
- package/tools/doc-parity.mjs +27 -4
- package/tools/exec-producer.mjs +483 -0
- package/tools/exec-receipt.mjs +263 -0
- package/tools/flow-store.mjs +111 -462
- package/tools/gates-declaration.mjs +49 -0
- package/tools/gates-init.mjs +83 -6
- package/tools/receipt-deadline.mjs +25 -3
- package/tools/recommendations.mjs +63 -19
- package/tools/release-scan.mjs +33 -0
- package/tools/run-gates.mjs +111 -32
- package/tools/store-append.mjs +444 -0
- package/tools/velocity-profile.mjs +102 -23
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dispatch.mjs — the delegation ENGINE (delegation Plan 1, Phase 3; the writer verbs are Plan 2,
|
|
3
|
+
// Phase 2 and the waiter Plan 2, Phase 4): the ONE human-facing surface over the record vocabulary
|
|
4
|
+
// (dispatch-record.mjs) and the ledger (dispatch-store.mjs). Nine verbs:
|
|
5
|
+
//
|
|
6
|
+
// check <dispatch-file> the D8 sub-task contract header, FORM-only, exit 0/1
|
|
7
|
+
// register the wave's PRE-REGISTRATION record (classes, pairing key, thresholds)
|
|
8
|
+
// observe one OBSERVATION record (the solo baseline / a self-reported datum)
|
|
9
|
+
// open the DISPATCH record — every mint-time field COPIED from the header
|
|
10
|
+
// await the ARRIVAL waiter — the ONLY verb that waits, and it writes nothing
|
|
11
|
+
// return the RETURN record — the wrapper's exec receipt ABSORBED through this door
|
|
12
|
+
// fold the FOLD record — the integration re-confirmation
|
|
13
|
+
// degrade the DEGRADE record — the recorded no-fold closure
|
|
14
|
+
// aggregate [--wave] the L0 deterministic report over ONE wave
|
|
15
|
+
//
|
|
16
|
+
// Why an engine at all: the funded metric — how much leverage a delegated sub-task actually buys —
|
|
17
|
+
// is unmeasurable while nothing records {dispatched → returned → folded}. The store records it; this
|
|
18
|
+
// surface is where a number enters the ledger by hand (a registration, an observation) and where the
|
|
19
|
+
// recorded threads are read back as a report. Delegated accounting is NEVER hand-appended: an
|
|
20
|
+
// `observation` carries only `solo-construction` and `self-reported` provenance, and the delegated
|
|
21
|
+
// per-class L is DERIVED here from terminal nonce threads (D7) — the writer verbs derive it from a
|
|
22
|
+
// wrapper-minted receipt and a git-side producer, never from a typed number.
|
|
23
|
+
//
|
|
24
|
+
// The WRITER verbs add no second legality door. The store's semantic preflight (dispatch-store.mjs)
|
|
25
|
+
// decides every cross-record rule under its lock; these verbs assemble a record, surface the store's
|
|
26
|
+
// refusals verbatim, and refuse ONLY what the store cannot see — the artifact a wrapper minted, the
|
|
27
|
+
// tree in front of them, and the flags a caller typed.
|
|
28
|
+
//
|
|
29
|
+
// The aggregator is deterministic exit-code/print, and it REFUSES rather than guessing: a wave with
|
|
30
|
+
// no pre-registration record, an OPEN thread in its scope, and an ambiguous wave selection each stop
|
|
31
|
+
// the computation by name. Acceptance is PRE-REGISTERED precisely so thresholds can never be chosen
|
|
32
|
+
// after the observations they judge; a computation that silently skipped an unfinished thread, or
|
|
33
|
+
// silently picked one of several waves, would give that back.
|
|
34
|
+
//
|
|
35
|
+
// Honest limits, stated where they bite:
|
|
36
|
+
// • the contract checker is FORM-only BY NAME (D-R1-FORM-ONLY): fields present, grammars
|
|
37
|
+
// respected. Whether the sub-task is BOUNDED, whether its design is DECIDED, and whether its
|
|
38
|
+
// acceptance is ADEQUATE stay explicit orchestrator judgment fed by the retro loop.
|
|
39
|
+
// • an observation is hand-recorded, so its magnitude is measured HERE: the scope names repository
|
|
40
|
+
// objects and the numerator is their post-image bytes on disk at observation time. For the SOLO
|
|
41
|
+
// baseline the denominator is that same number — the orchestrator authored every byte it also
|
|
42
|
+
// integrated, so L = 1 EXACTLY, by construction, whatever the magnitude turns out to be; the
|
|
43
|
+
// ratio is the only quantity acceptance ever consumes.
|
|
44
|
+
// • acceptance aggregates the `wrapper-git` domain only (D6/R2). A self-reported observation and a
|
|
45
|
+
// folded return whose bytes the wrapper could not prove are both RECORDED and PRINTED, and both
|
|
46
|
+
// stay out of the mean — recorded-but-excluded, never a silent drop.
|
|
47
|
+
// • the scope measurement classifies (no-follow lstat) and then reads; the pair is not race-free.
|
|
48
|
+
// The scope is the orchestrator's OWN work tree and the result is a MAGNITUDE, never a store
|
|
49
|
+
// identity, so a pathname race costs a wrong byte count, not a forged record.
|
|
50
|
+
// • `return` never accounts GATE OUTPUT: the wrapper's EXIT trap removes its trace, so no
|
|
51
|
+
// `gate-output` component is emitted in v1. The metric counts the returned change set only.
|
|
52
|
+
// • a receipt is FORGEABLE, exactly like every record in this family. What the absorb door defends
|
|
53
|
+
// against is a buggy or interrupted producer, not a hostile one.
|
|
54
|
+
// • D10 stands as a bar, not a mechanism: at most ONE in-tree exec dispatch runs at a time, and
|
|
55
|
+
// nothing here refuses a second one.
|
|
56
|
+
// • `await` observes ARRIVAL and nothing else: an expiry never authorizes the next writer, and the
|
|
57
|
+
// verb releases no slot it never held. Whether the run may be ABSORBED stays `return`'s question. A parallel-write story is not this plan's.
|
|
58
|
+
//
|
|
59
|
+
// Writer: appends to the delegation ledger through the store's lock-serialized append (the store's
|
|
60
|
+
// preflight is the single legality door — this module adds NO second validator). Never commits,
|
|
61
|
+
// never runs a subscription CLI, spawns nothing but git READS. Dependency-free, Node >= 22. No side
|
|
62
|
+
// effects on import (the isDirectRun idiom).
|
|
63
|
+
|
|
64
|
+
import { readFileSync, openSync, closeSync, realpathSync, readlinkSync, constants as fsConstants } from 'node:fs';
|
|
65
|
+
import { resolve, isAbsolute, sep, dirname, basename, join } from 'node:path';
|
|
66
|
+
import { fileURLToPath } from 'node:url';
|
|
67
|
+
import { createHash } from 'node:crypto';
|
|
68
|
+
import {
|
|
69
|
+
DELEGATION_SCHEMA_VERSION, STEP_CLASSES, OBSERVATION_PROVENANCE, RETURN_OUTCOMES,
|
|
70
|
+
SESSION_ID_NULLABLE_OUTCOMES, checkDispatchContractForm, checkDispatchMintConsistency,
|
|
71
|
+
contractDigest, canonicalDelegationDigest, computeNumerator, evaluateMetricEligibility,
|
|
72
|
+
evaluateObservationEligibility,
|
|
73
|
+
} from './dispatch-record.mjs';
|
|
74
|
+
import {
|
|
75
|
+
appendDelegationRecord, readDelegationStore, resolveDelegationStorePath, delegationThreadState,
|
|
76
|
+
auditDelegationStoreSemantics, uncommittedStateFingerprint, DELEGATION_STORE_BASENAME,
|
|
77
|
+
} from './dispatch-store.mjs';
|
|
78
|
+
import { execReceiptBasename, execReportBasename, parseExecReceipt } from './exec-receipt.mjs';
|
|
79
|
+
import {
|
|
80
|
+
enumerateReturnedObjects, computeReturnedDiff, assembleIntegrationBundle,
|
|
81
|
+
} from './exec-producer.mjs';
|
|
82
|
+
import { gitBuf, isTreeClean } from './core-evidence.mjs';
|
|
83
|
+
import { lstatNoFollowRead, describeNonRegular, readRegularFileNoFollow, readFileBytesNoFollow } from './fs-read-nofollow.mjs';
|
|
84
|
+
import { gitLine } from './flow-store-read.mjs';
|
|
85
|
+
import { lexicalRepoRelative } from './repo-lex.mjs';
|
|
86
|
+
|
|
87
|
+
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
88
|
+
|
|
89
|
+
// The ONE contract sentence, doc-parity-bound into references/modes/dispatch.md: the FORM-only limit
|
|
90
|
+
// and the aggregator's refusals are what a reader must not be able to mis-learn from the mode doc.
|
|
91
|
+
export const DISPATCH_CONTRACT = 'the contract check is FORM-only — fields present, grammars respected, never boundedness, design-decidedness or acceptance adequacy — and `aggregate` REFUSES instead of computing acceptance for a wave with no pre-registration record, over an OPEN thread in scope, over a PRE-DISPATCH degrade that opens no thread, or across several waves with no `--wave`; the writer verbs add NO second legality door — the store\'s preflight is the only one, and its refusals travel verbatim — while `open` copies every mint-time field from the contract header and refuses a deadline below the wrapper cap plus the kill grace, `return` absorbs only a TERMINAL exec receipt whose backend, nonce and independently computed contractDigest match the dispatch it answers, and `fold` binds the CURRENT tree to the folded return\'s postTreeDigest, so a tree that moved between the two never folds, and `await` is satisfied ONLY by the TERMINAL exec receipt of its own dispatch\'s {backend, nonce} — never by a review receipt, a ledger line or a finding manifest — while an expiry names a supervision question and releases NO writer slot';
|
|
92
|
+
|
|
93
|
+
// ── the flag surface (the CLI tests pin it against the D3 key sets) ────────────────────────────────
|
|
94
|
+
// flag → the record field it decides. The fields NOT on a flag are DERIVED and listed beside them,
|
|
95
|
+
// so "the surface mirrors the key set" is a set equality a test can compute rather than read.
|
|
96
|
+
|
|
97
|
+
export const REGISTER_FLAG_FIELDS = Object.freeze({
|
|
98
|
+
'--wave': 'waveId',
|
|
99
|
+
'--step-classes': 'stepClasses',
|
|
100
|
+
'--pairing-key': 'pairingKey',
|
|
101
|
+
'--min-per-class': 'minPerClass',
|
|
102
|
+
'--mean-l-threshold': 'meanLThreshold',
|
|
103
|
+
'--first-pass-num': 'firstPassNum',
|
|
104
|
+
'--first-pass-den': 'firstPassDen',
|
|
105
|
+
});
|
|
106
|
+
export const REGISTER_DERIVED_FIELDS = Object.freeze(['timestamp']);
|
|
107
|
+
|
|
108
|
+
export const OBSERVE_FLAG_FIELDS = Object.freeze({
|
|
109
|
+
'--wave': 'waveId',
|
|
110
|
+
'--step-class': 'stepClass',
|
|
111
|
+
'--scope': 'scope',
|
|
112
|
+
'--plan': 'planId',
|
|
113
|
+
'--phase': 'phase',
|
|
114
|
+
'--provenance': 'metric.provenance',
|
|
115
|
+
'--denominator-bytes': 'metric.denominatorBytes',
|
|
116
|
+
});
|
|
117
|
+
export const OBSERVE_DERIVED_FIELDS = Object.freeze(['timestamp']);
|
|
118
|
+
|
|
119
|
+
// The four writer verbs. Their key sets are larger than their flag surfaces on purpose: a delegated
|
|
120
|
+
// record is DERIVED — from the contract header, from the wrapper's receipt, from the tree — and the
|
|
121
|
+
// fields a caller may type are exactly the ones no artifact can supply. Three exported categories
|
|
122
|
+
// per verb (flag-decided · copied · derived) keep "the surface mirrors the key set" a set equality a
|
|
123
|
+
// test computes, and keep the INPUT flags that decide no field of their own visible rather than
|
|
124
|
+
// hidden inside the scanner.
|
|
125
|
+
|
|
126
|
+
export const OPEN_FLAG_FIELDS = Object.freeze({
|
|
127
|
+
'--wave': 'waveId',
|
|
128
|
+
'--backend': 'backend',
|
|
129
|
+
'--rationale': 'rationale',
|
|
130
|
+
'--retry-of': 'retryOf',
|
|
131
|
+
});
|
|
132
|
+
// COPIED from the contract header at mint and bound by contractDigest (D3): a dispatch that
|
|
133
|
+
// disagreed with the header it claims to carry is refused by checkDispatchMintConsistency.
|
|
134
|
+
export const OPEN_CONTRACT_FIELDS = Object.freeze(['nonce', 'stepClass', 'vehicle', 'deadlineS', 'retryIndex', 'retryCap']);
|
|
135
|
+
export const OPEN_DERIVED_FIELDS = Object.freeze(['contractDigest', 'preTreeDigest', 'baselineClean', 'timestamp']);
|
|
136
|
+
// Flags that carry NO record field: the header they read and the two operands the D8 floor is
|
|
137
|
+
// computed from. The kit never reads another package's default — both are explicit.
|
|
138
|
+
export const OPEN_INPUT_FLAGS = Object.freeze({
|
|
139
|
+
'--contract': 'the dispatch file whose header every mint-time field is copied from',
|
|
140
|
+
'--wrapper-cap-s': 'the wall-clock cap the wrapper will apply (the D8 floor)',
|
|
141
|
+
'--kill-grace-s': 'the kill grace the wrapper will apply (the D8 floor)',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const RETURN_FLAG_FIELDS = Object.freeze({
|
|
145
|
+
'--nonce': 'nonce',
|
|
146
|
+
'--outcome': 'outcome',
|
|
147
|
+
'--exit-status': 'exitStatus',
|
|
148
|
+
});
|
|
149
|
+
export const RETURN_DERIVED_FIELDS = Object.freeze([
|
|
150
|
+
'role', 'backend', 'contractDigest', 'preTreeDigest', 'postTreeDigest', 'diffDigest', 'diffLength',
|
|
151
|
+
'reportDigest', 'reportLength', 'bundleDigest', 'bundleLength', 'metric', 'sessionId',
|
|
152
|
+
'wrapperVersion', 'posture', 'timestamp',
|
|
153
|
+
]);
|
|
154
|
+
export const RETURN_INPUT_FLAGS = Object.freeze({
|
|
155
|
+
'--no-receipt': 'absorb WITHOUT the terminal receipt (D5) — the reservation supplies the pre-spend fields',
|
|
156
|
+
});
|
|
157
|
+
const RETURN_BOOLEAN_FLAGS = new Set(['--no-receipt']);
|
|
158
|
+
|
|
159
|
+
export const FOLD_FLAG_FIELDS = Object.freeze({
|
|
160
|
+
'--nonce': 'nonce',
|
|
161
|
+
'--verdict': 'verdict',
|
|
162
|
+
});
|
|
163
|
+
export const FOLD_DERIVED_FIELDS = Object.freeze(['returnDigest', 'treeDigestAtFold', 'timestamp']);
|
|
164
|
+
|
|
165
|
+
export const DEGRADE_FLAG_FIELDS = Object.freeze({
|
|
166
|
+
'--wave': 'waveId',
|
|
167
|
+
'--nonce': 'nonce',
|
|
168
|
+
'--step-class': 'stepClass',
|
|
169
|
+
'--rationale': 'rationale',
|
|
170
|
+
});
|
|
171
|
+
export const DEGRADE_DERIVED_FIELDS = Object.freeze(['timestamp']);
|
|
172
|
+
|
|
173
|
+
// D4 — the closed override form. At `return` the wrapper's own outcome either STAYS itself or moves
|
|
174
|
+
// to one of the orchestrator-only judgments. `success` is not among them, so it is recordable only
|
|
175
|
+
// from a receipt that already says `success`; the record vocabulary's cross-field rules
|
|
176
|
+
// (dispatch-record.mjs) then cut the remaining incompatible pairs, and this form never restates them.
|
|
177
|
+
export const ORCHESTRATOR_OUTCOMES = Object.freeze(['contract-refusal', 'partial-edit', 'acceptance-failure', 'stale-return', 'store-failure']);
|
|
178
|
+
export const allowedRecordedOutcomes = (wrapperOutcome) =>
|
|
179
|
+
[wrapperOutcome, ...ORCHESTRATOR_OUTCOMES.filter((o) => o !== wrapperOutcome)];
|
|
180
|
+
|
|
181
|
+
// The bytes a returned metric is proven from are git's, on both sides of the ratio — so a return
|
|
182
|
+
// this engine mints is always the wrapper-git domain. `self-reported` stays expressible in the
|
|
183
|
+
// vocabulary (a record the ledger accepts) but no writer here produces one.
|
|
184
|
+
const RETURN_PROVENANCE_HERE = 'wrapper-git';
|
|
185
|
+
|
|
186
|
+
// `--scope` is REPEATABLE — one repo-relative path per occurrence. No in-band separator is safe for
|
|
187
|
+
// POSIX paths (every byte but NUL and `/` is legal in a name), so a split on whitespace could not
|
|
188
|
+
// express `docs/my file.md` at all and, where the fragments happened to be real files, would measure
|
|
189
|
+
// the wrong set in silence. The record's `scope` field carries the canonical JSON array instead.
|
|
190
|
+
const REPEATABLE_NONE = new Set();
|
|
191
|
+
const OBSERVE_REPEATABLE = new Set(['--scope']);
|
|
192
|
+
|
|
193
|
+
// The pairing keys the AGGREGATOR actually implements. The schema types `pairingKey` as a free
|
|
194
|
+
// string so a future key is expressible, but a wave registered under a key the engine never honours
|
|
195
|
+
// would record a contract the computation does not follow — refused at both ends.
|
|
196
|
+
export const IMPLEMENTED_PAIRING_KEYS = Object.freeze(['stepClass']);
|
|
197
|
+
|
|
198
|
+
// The solo baseline counts each scope object's POST-IMAGE — the bytes on disk after the construction
|
|
199
|
+
// — which is exactly the `new` numerator rule (D6). No exec diff kind enumerates ranges, so a solo
|
|
200
|
+
// observation can never claim a partial object.
|
|
201
|
+
const SOLO_COMPONENT_KIND = 'new';
|
|
202
|
+
|
|
203
|
+
// Acceptance aggregates the git-provable domain only (D6/R2).
|
|
204
|
+
const ACCEPTANCE_PROVENANCE = 'wrapper-git';
|
|
205
|
+
|
|
206
|
+
// ── argument parsing ──────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
const CWD_FLAG = '--cwd';
|
|
209
|
+
const HELP_FLAGS = new Set(['--help', '-h']);
|
|
210
|
+
|
|
211
|
+
// ONE left-to-right pass per verb: a flag consumes the NEXT token as its value, and that token is
|
|
212
|
+
// never examined again by anything else. A SECOND scanner over the same vector is the defect class
|
|
213
|
+
// itself — it reads a value as a control argument, so a scope path named `--help` silently printed
|
|
214
|
+
// help and wrote nothing, and a `--cwd` search stole whichever token followed the first match.
|
|
215
|
+
// Anything that is not a known flag is a positional operand; an unknown `--flag` refuses by name.
|
|
216
|
+
// A BOOLEAN flag consumes no token at all — it is present or it is not. It rides the same single
|
|
217
|
+
// pass rather than a pre-scan for exactly the reason above: a second look at the vector is how a
|
|
218
|
+
// flag's VALUE becomes someone else's control argument.
|
|
219
|
+
const parseArgs = (argv, flags, repeatable = REPEATABLE_NONE, booleans = REPEATABLE_NONE) => {
|
|
220
|
+
const values = {};
|
|
221
|
+
const operands = [];
|
|
222
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
223
|
+
const token = argv[i];
|
|
224
|
+
if (!Object.hasOwn(flags, token)) {
|
|
225
|
+
// A help token past the verb is an ORDINARY operand — its control meaning is scoped to the
|
|
226
|
+
// first argument, so a file or path actually named `--help` stays reachable. Any other
|
|
227
|
+
// unknown `--flag` still refuses by name.
|
|
228
|
+
if (token.startsWith('--') && !HELP_FLAGS.has(token)) {
|
|
229
|
+
throw usageFail(`unknown argument: ${token} — this verb's flags are ${Object.keys(flags).join(' ')}`);
|
|
230
|
+
}
|
|
231
|
+
operands.push(token);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (Object.hasOwn(values, token) && !repeatable.has(token)) {
|
|
235
|
+
throw usageFail(`${token} was given twice — every flag carries exactly one value`);
|
|
236
|
+
}
|
|
237
|
+
if (booleans.has(token)) {
|
|
238
|
+
values[token] = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const value = argv[i + 1];
|
|
242
|
+
if (value === undefined) throw usageFail(`${token} needs a value`);
|
|
243
|
+
if (repeatable.has(token)) (values[token] ??= []).push(value);
|
|
244
|
+
else values[token] = value;
|
|
245
|
+
i += 1;
|
|
246
|
+
}
|
|
247
|
+
return { values, operands };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Every verb accepts `--cwd` as an ordinary flag of its own scan — never as a pre-pass.
|
|
251
|
+
const scan = (argv, flagFields, baseCwd, { repeatable, booleans } = {}) => {
|
|
252
|
+
const { values, operands } = parseArgs(argv, { ...flagFields, [CWD_FLAG]: 'cwd' }, repeatable, booleans);
|
|
253
|
+
return { values, operands, cwd: values[CWD_FLAG] ?? baseCwd };
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const refuseOperands = (verb, operands) => {
|
|
257
|
+
if (operands.length > 0) throw usageFail(`unknown argument: ${operands[0]} — ${verb} takes flags only`);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const need = (values, flag) => {
|
|
261
|
+
const value = values[flag];
|
|
262
|
+
if (value === undefined) throw usageFail(`${flag} is required`);
|
|
263
|
+
return value;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const INTEGER_RE = /^(?:0|[1-9][0-9]*)$/;
|
|
267
|
+
const asInteger = (flag, raw) => {
|
|
268
|
+
if (!INTEGER_RE.test(raw)) throw usageFail(`${flag} must be a non-negative decimal integer (got "${raw}")`);
|
|
269
|
+
const value = Number(raw);
|
|
270
|
+
// Past the safe range an exact byte comparison is no longer possible, and the record vocabulary
|
|
271
|
+
// refuses such a count — refuse it HERE, where the flag that carried it can be named.
|
|
272
|
+
if (!Number.isSafeInteger(value)) throw usageFail(`${flag} leaves the safe-integer range (got "${raw}")`);
|
|
273
|
+
return value;
|
|
274
|
+
};
|
|
275
|
+
const asPositiveInteger = (flag, raw) => {
|
|
276
|
+
const value = asInteger(flag, raw);
|
|
277
|
+
if (value < 1) throw usageFail(`${flag} must be at least 1 (got "${raw}")`);
|
|
278
|
+
return value;
|
|
279
|
+
};
|
|
280
|
+
const asNumber = (flag, raw) => {
|
|
281
|
+
const value = Number(raw);
|
|
282
|
+
if (raw.trim() === '' || !Number.isFinite(value)) throw usageFail(`${flag} must be a finite number (got "${raw}")`);
|
|
283
|
+
return value;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const refusal = (verb, reason) => ({ code: 1, stdout: '', stderr: `dispatch ${verb}: ${reason}` });
|
|
287
|
+
|
|
288
|
+
// ── check: the D8 contract header, FORM only ──────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
const runCheck = ({ argv, baseCwd }) => {
|
|
291
|
+
const { operands, cwd } = scan(argv, {}, baseCwd);
|
|
292
|
+
if (operands.length > 1) throw usageFail(`unknown argument: ${operands[1]}`);
|
|
293
|
+
const file = operands[0];
|
|
294
|
+
if (file === undefined) throw usageFail('check needs a dispatch file: node dispatch.mjs check <dispatch-file>');
|
|
295
|
+
const path = isAbsolute(file) ? file : resolve(cwd, file);
|
|
296
|
+
let text;
|
|
297
|
+
try {
|
|
298
|
+
text = readFileSync(path, 'utf8');
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return { code: 1, stdout: '', stderr: `dispatch check: cannot read ${file} (${(err && err.code) || (err && err.message)})` };
|
|
301
|
+
}
|
|
302
|
+
const form = checkDispatchContractForm(text);
|
|
303
|
+
if (!form.ok) return { code: 1, stdout: `dispatch check: FORM VIOLATION — ${form.reason}`, stderr: '' };
|
|
304
|
+
const c = form.contract;
|
|
305
|
+
return {
|
|
306
|
+
code: 0,
|
|
307
|
+
stdout: `dispatch check: FORM OK — nonce "${c.nonce}", step class "${c.stepClass}", vehicle ${c.vehicle.requested} → ${c.vehicle.selected}, deadline ${c.deadlineS}s, retry ${c.retry.index}/${c.retry.cap}\n (${DISPATCH_CONTRACT})`,
|
|
308
|
+
stderr: '',
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// ── register: the wave's pre-registration ─────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const requireImplementedPairingKey = (pairingKey) => {
|
|
315
|
+
if (!IMPLEMENTED_PAIRING_KEYS.includes(pairingKey)) {
|
|
316
|
+
throw usageFail(`--pairing-key must be one of ${IMPLEMENTED_PAIRING_KEYS.join(' | ')} (got "${pairingKey}") — a wave registered under a key the aggregator does not honour would record a contract the computation never follows`);
|
|
317
|
+
}
|
|
318
|
+
return pairingKey;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const runRegister = ({ baseCwd, env, argv, now }) => {
|
|
322
|
+
const { values, operands, cwd } = scan(argv, REGISTER_FLAG_FIELDS, baseCwd);
|
|
323
|
+
refuseOperands('register', operands);
|
|
324
|
+
const record = {
|
|
325
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
326
|
+
kind: 'pre-registration',
|
|
327
|
+
waveId: need(values, '--wave'),
|
|
328
|
+
stepClasses: need(values, '--step-classes').split(',').filter((c) => c !== ''),
|
|
329
|
+
pairingKey: requireImplementedPairingKey(need(values, '--pairing-key')),
|
|
330
|
+
minPerClass: asInteger('--min-per-class', need(values, '--min-per-class')),
|
|
331
|
+
meanLThreshold: asNumber('--mean-l-threshold', need(values, '--mean-l-threshold')),
|
|
332
|
+
firstPassNum: asInteger('--first-pass-num', need(values, '--first-pass-num')),
|
|
333
|
+
firstPassDen: asInteger('--first-pass-den', need(values, '--first-pass-den')),
|
|
334
|
+
timestamp: now(),
|
|
335
|
+
};
|
|
336
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
337
|
+
return {
|
|
338
|
+
code: 0,
|
|
339
|
+
stdout: `dispatch register: wave "${record.waveId}" registered — classes ${record.stepClasses.join(' | ')} · pairing key ${record.pairingKey} · minimum ${record.minPerClass} per class · mean L >= ${record.meanLThreshold} · first pass >= ${record.firstPassNum}/${record.firstPassDen} → ${writtenPath}`,
|
|
340
|
+
stderr: '',
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ── observe: one hand-recorded observation ────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
const ratio = (value) => value.toFixed(3);
|
|
347
|
+
|
|
348
|
+
// L is printed ONLY where the metric is eligible: an ineligible metric has a NAMED reason and no
|
|
349
|
+
// ratio at all, and printing a number beside the name is how a silent zero gets read as a measurement.
|
|
350
|
+
const formatRatio = (metric) => (metric.eligible
|
|
351
|
+
? `L = ${ratio(metric.numeratorBytes / metric.denominatorBytes)} (${metric.numeratorBytes} B / ${metric.denominatorBytes} B)`
|
|
352
|
+
: `L = n/a — INELIGIBLE (${metric.ineligibleReason})`);
|
|
353
|
+
|
|
354
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
355
|
+
|
|
356
|
+
// The scope's anchor is the git TOP-LEVEL, never the caller's cwd: a recorded scope must name the
|
|
357
|
+
// same objects whoever runs the tool from wherever. `null` outside a work tree — a repo-relative
|
|
358
|
+
// domain with no repository has nothing to be relative TO, and falling back to cwd would be a
|
|
359
|
+
// second, incompatible semantics for the same field.
|
|
360
|
+
const resolveRepoRoot = (cwd) => {
|
|
361
|
+
if (gitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
|
|
362
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
363
|
+
return top === null ? null : realpathSync(top);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// The read is no-follow on the LEAF (a symlinked leaf is already refused by name above; O_NOFOLLOW
|
|
367
|
+
// makes a swap between the classification and the read fail loudly rather than counting another
|
|
368
|
+
// object's bytes). Honest limit: classify-then-read is not race-free, and it is not meant to be —
|
|
369
|
+
// the scope is the orchestrator's OWN work tree and the result is a MAGNITUDE, never a store
|
|
370
|
+
// identity, so a pathname race costs a wrong byte count, not a forged record.
|
|
371
|
+
const readObjectBytes = (path) => {
|
|
372
|
+
const fd = openSync(path, (fsConstants.O_RDONLY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
373
|
+
try {
|
|
374
|
+
return readFileSync(fd);
|
|
375
|
+
} finally {
|
|
376
|
+
closeSync(fd);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// One scope object → one numerator entry. Refuses by NAME on anything it cannot count honestly: a
|
|
381
|
+
// path escaping the repo LEXICALLY, an absent path, a non-regular path (a symlinked leaf included —
|
|
382
|
+
// following one would count another object's bytes under this name), and a path whose REAL location
|
|
383
|
+
// is outside the repository. The last one is the case the lexical rule alone cannot see: it rejects
|
|
384
|
+
// `../x` while accepting `link/x`, where `link` is an ancestor symlink pointing out of the tree.
|
|
385
|
+
// The identity is the CANONICAL repo-relative path taken from the verified real path — not a content
|
|
386
|
+
// hash. The solo domain has no rename lineage for a content id to protect, and a content id would
|
|
387
|
+
// let one object read between two measurements look like TWO objects instead of refusing as the
|
|
388
|
+
// producer contradiction it is ("one identity, one size"). Two equal-byte files at different paths
|
|
389
|
+
// are two objects and count twice; one path reached twice (a second listing, an in-repo ancestor
|
|
390
|
+
// symlink) is one object and counts once.
|
|
391
|
+
const measureObject = (root, rel) => {
|
|
392
|
+
const lexical = lexicalRepoRelative(rel);
|
|
393
|
+
if (!lexical.ok) return { ok: false, reason: `scope path "${rel}": ${lexical.reason}` };
|
|
394
|
+
const path = resolve(root, rel);
|
|
395
|
+
const stat = lstatNoFollowRead(path);
|
|
396
|
+
if (stat === null) return { ok: false, reason: `scope path "${rel}" does not exist — an observation counts objects that are actually there (fail closed)` };
|
|
397
|
+
if (!stat.isFile()) return { ok: false, reason: `scope path "${rel}" is a ${describeNonRegular(stat)}, not a regular file — the scope names repository objects (fail closed)` };
|
|
398
|
+
const real = realpathSync(path);
|
|
399
|
+
if (!real.startsWith(`${root}${sep}`)) {
|
|
400
|
+
return { ok: false, reason: `scope path "${rel}" resolves to ${real}, which leaves the repository at ${root} — an ancestor symlink is not a way out of the scope domain (fail closed)` };
|
|
401
|
+
}
|
|
402
|
+
const canonical = real.slice(root.length + 1);
|
|
403
|
+
const bytes = readObjectBytes(path);
|
|
404
|
+
return { ok: true, entry: { kind: SOLO_COMPONENT_KIND, path: canonical, objectId: canonical, postImageBytes: bytes.length } };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// One repo-relative path per `--scope` occurrence, in the order given. The measured CANONICAL paths
|
|
408
|
+
// become the record's `scope` as a canonical JSON array, so what was measured and what is written
|
|
409
|
+
// down are the same statement — and a path carrying a space says so unambiguously.
|
|
410
|
+
const measureScope = (root, paths) => {
|
|
411
|
+
const entries = [];
|
|
412
|
+
for (const rel of paths) {
|
|
413
|
+
const measured = measureObject(root, rel);
|
|
414
|
+
if (!measured.ok) return measured;
|
|
415
|
+
entries.push(measured.entry);
|
|
416
|
+
}
|
|
417
|
+
const numerator = computeNumerator(entries);
|
|
418
|
+
return numerator.ok
|
|
419
|
+
? { ...numerator, scope: JSON.stringify(entries.map((e) => e.path)) }
|
|
420
|
+
: { ok: false, reason: numerator.reason };
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const runObserve = ({ baseCwd, env, argv, now }) => {
|
|
424
|
+
const { values, operands, cwd } = scan(argv, OBSERVE_FLAG_FIELDS, baseCwd, { repeatable: OBSERVE_REPEATABLE });
|
|
425
|
+
refuseOperands('observe', operands);
|
|
426
|
+
const provenance = need(values, '--provenance');
|
|
427
|
+
if (!OBSERVATION_PROVENANCE.includes(provenance)) {
|
|
428
|
+
throw usageFail(`--provenance must be one of ${OBSERVATION_PROVENANCE.join(' | ')} (got "${provenance}") — delegated accounting is DERIVED from nonce threads, never hand-appended`);
|
|
429
|
+
}
|
|
430
|
+
const paths = need(values, '--scope');
|
|
431
|
+
const root = resolveRepoRoot(cwd);
|
|
432
|
+
if (root === null) {
|
|
433
|
+
return { code: 1, stdout: '', stderr: 'dispatch observe: not inside a git work tree — a scope names REPOSITORY objects, so without a repository there is nothing its paths are relative to (fail closed)' };
|
|
434
|
+
}
|
|
435
|
+
const measured = measureScope(root, paths);
|
|
436
|
+
if (!measured.ok) return { code: 1, stdout: '', stderr: `dispatch observe: ${measured.reason}` };
|
|
437
|
+
// The SOLO baseline's denominator is its own numerator: the orchestrator authored every byte it
|
|
438
|
+
// also integrated, so L = 1 by construction and no caller may type that number. A self-reported
|
|
439
|
+
// observation states the integration cost it claims — and is excluded from acceptance downstream.
|
|
440
|
+
const solo = provenance === 'solo-construction';
|
|
441
|
+
if (solo && values['--denominator-bytes'] !== undefined) {
|
|
442
|
+
throw usageFail('--denominator-bytes is refused for --provenance solo-construction — the solo denominator IS the measured numerator (L = 1 by construction)');
|
|
443
|
+
}
|
|
444
|
+
const denominatorBytes = solo
|
|
445
|
+
? measured.numeratorBytes
|
|
446
|
+
: asInteger('--denominator-bytes', need(values, '--denominator-bytes'));
|
|
447
|
+
const eligibility = evaluateObservationEligibility({ numeratorBytes: measured.numeratorBytes, denominatorBytes });
|
|
448
|
+
const record = {
|
|
449
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
450
|
+
kind: 'observation',
|
|
451
|
+
waveId: need(values, '--wave'),
|
|
452
|
+
stepClass: need(values, '--step-class'),
|
|
453
|
+
scope: measured.scope,
|
|
454
|
+
metric: {
|
|
455
|
+
numeratorBytes: measured.numeratorBytes,
|
|
456
|
+
denominatorBytes,
|
|
457
|
+
components: measured.components,
|
|
458
|
+
provenance,
|
|
459
|
+
eligible: eligibility.eligible,
|
|
460
|
+
ineligibleReason: eligibility.ineligibleReason,
|
|
461
|
+
},
|
|
462
|
+
planId: need(values, '--plan'),
|
|
463
|
+
phase: asInteger('--phase', need(values, '--phase')),
|
|
464
|
+
timestamp: now(),
|
|
465
|
+
};
|
|
466
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
467
|
+
// DISTINCT objects, not scope entries: the numerator dedups on the canonical path, so counting
|
|
468
|
+
// entries would report two objects where one was counted — the echo must agree with the number.
|
|
469
|
+
const objects = new Set(record.metric.components.map((c) => c.objectId)).size;
|
|
470
|
+
return {
|
|
471
|
+
code: 0,
|
|
472
|
+
stdout: `dispatch observe: recorded a ${provenance} observation in wave "${record.waveId}" — class ${record.stepClass} · plan ${record.planId} phase ${record.phase} · ${formatRatio(record.metric)} · ${objects} object(s) → ${writtenPath}`,
|
|
473
|
+
stderr: '',
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// ── the shared ledger read: legality re-established BEFORE any verb computes over it (D14) ────────
|
|
478
|
+
|
|
479
|
+
// The append path refuses a malformed LINE, but nothing replayed a semantically illegal PREFIX that
|
|
480
|
+
// some other producer wrote — and a verb computing over such a ledger inherits its lie. So every
|
|
481
|
+
// verb that DERIVES anything from the store (`return`, `fold`, `degrade`, `aggregate`) reads it
|
|
482
|
+
// through this one door, which replays the store's own preflight in file order and stops at the
|
|
483
|
+
// first record the append path would have refused, naming its physical line.
|
|
484
|
+
//
|
|
485
|
+
// `open` deliberately has no read-side audit: it derives nothing from the ledger. Its retry and wave
|
|
486
|
+
// rules are evaluated by the store, on the snapshot under the lock, which is the only place they can
|
|
487
|
+
// be decided without a race.
|
|
488
|
+
const readLegalLedger = (cwd, env) => {
|
|
489
|
+
const path = resolveDelegationStorePath(cwd, env);
|
|
490
|
+
if (path === null) {
|
|
491
|
+
return { ok: false, reason: 'not inside a git work tree (and no AW_DELEGATION_STORE override) — there is no delegation store to read' };
|
|
492
|
+
}
|
|
493
|
+
const store = readDelegationStore(path);
|
|
494
|
+
if (store.readError !== undefined) return { ok: false, reason: `${path} — ${store.readError}` };
|
|
495
|
+
if (store.malformed > 0) {
|
|
496
|
+
return { ok: false, reason: `${path} carries ${store.malformed} malformed line(s) — a dropped line could hide a thread the computation must see (fail closed): ${store.malformedReasons.join('; ')}` };
|
|
497
|
+
}
|
|
498
|
+
const audit = auditDelegationStoreSemantics({ records: store.records, recordLines: store.recordLines, storePath: path });
|
|
499
|
+
if (!audit.ok) {
|
|
500
|
+
return { ok: false, reason: `${path} line ${audit.line} carries a record the append path would have REFUSED — ${audit.reason}` };
|
|
501
|
+
}
|
|
502
|
+
// The receipt artifacts live BESIDE the ledger — one resolution, so the kit reads exactly where
|
|
503
|
+
// the wrapper writes (the bash mirror of this path is the bridge's, in Phase 3).
|
|
504
|
+
return { ok: true, path, dir: dirname(path), records: store.records };
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// ── open: the DISPATCH record ─────────────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
const runOpen = ({ baseCwd, env, argv, now }) => {
|
|
510
|
+
const { values, operands, cwd } = scan(argv, { ...OPEN_FLAG_FIELDS, ...OPEN_INPUT_FLAGS }, baseCwd);
|
|
511
|
+
refuseOperands('open', operands);
|
|
512
|
+
const file = need(values, '--contract');
|
|
513
|
+
// Both operands are EXPLICIT: the kit never reads another package's default, because a cap it
|
|
514
|
+
// guessed would put a floor nobody agreed to under a deadline the ledger then enforces.
|
|
515
|
+
const capS = asPositiveInteger('--wrapper-cap-s', need(values, '--wrapper-cap-s'));
|
|
516
|
+
const killGraceS = asInteger('--kill-grace-s', need(values, '--kill-grace-s'));
|
|
517
|
+
const path = isAbsolute(file) ? file : resolve(cwd, file);
|
|
518
|
+
let text;
|
|
519
|
+
try {
|
|
520
|
+
text = readFileSync(path, 'utf8');
|
|
521
|
+
} catch (err) {
|
|
522
|
+
return refusal('open', `cannot read ${file} (${(err && err.code) || (err && err.message)})`);
|
|
523
|
+
}
|
|
524
|
+
const form = checkDispatchContractForm(text);
|
|
525
|
+
if (!form.ok) return refusal('open', `FORM VIOLATION — ${form.reason}`);
|
|
526
|
+
const contract = form.contract;
|
|
527
|
+
// D8's floor, checked BEFORE anything is written: a dispatch whose deadline expires before its own
|
|
528
|
+
// wrapper can be capped and killed could never honour the terminal-exit rule — the waiter would
|
|
529
|
+
// report an expiry while the run was still legitimately alive, and no writer slot would be free.
|
|
530
|
+
const floorS = capS + killGraceS;
|
|
531
|
+
if (contract.deadlineS < floorS) {
|
|
532
|
+
return refusal('open', `the contract's deadlineS ${contract.deadlineS} is below the wrapper cap ${capS} plus the kill grace ${killGraceS} (${floorS}) — a dispatch that cannot be capped inside its own deadline can never honour the terminal-exit rule; nothing was written`);
|
|
533
|
+
}
|
|
534
|
+
// The baseline is probed BEFORE the fingerprint, because it is the one that can answer "there is
|
|
535
|
+
// no repository here" as a NAMED refusal — the fingerprint's own answer to that is a thrown STOP,
|
|
536
|
+
// and a verb should refuse in its own words before another module has to. Not decidable is never
|
|
537
|
+
// CLEAN: baselineClean:false is what makes the eventual return honestly ineligible, and guessing
|
|
538
|
+
// true would let an unattributable metric into the acceptance number.
|
|
539
|
+
const baselineClean = isTreeClean(cwd);
|
|
540
|
+
if (baselineClean === null) {
|
|
541
|
+
return refusal('open', 'the working state could not be probed, so the baseline is undecidable — a dispatch never records a guessed baseline, and outside a git work tree there is no tree to fingerprint either (fail closed); nothing was written');
|
|
542
|
+
}
|
|
543
|
+
const top = resolveRepoRoot(cwd);
|
|
544
|
+
if (top === null) return refusal('open', 'not inside a git work tree — a dispatch is opened against a repository (fail closed); nothing was written');
|
|
545
|
+
const placed = canonicalStoreRefusal(cwd, env);
|
|
546
|
+
if (placed !== null) return refusal('open', `${placed}; nothing was written`);
|
|
547
|
+
// ONE nonce, ONE artifact pair — checked PRE-SPEND, which is also where the wrapper's own
|
|
548
|
+
// no-clobber reservation will refuse from its side. An artifact that already exists for this
|
|
549
|
+
// {backend, nonce} was minted by something else, and absorbing it later would answer this dispatch
|
|
550
|
+
// with another run's evidence.
|
|
551
|
+
const leftover = existingArtifactRefusal(dirname(resolveDelegationStorePath(cwd, env)), need(values, '--backend'), contract.nonce);
|
|
552
|
+
if (leftover !== null) return refusal('open', `${leftover}; nothing was written`);
|
|
553
|
+
// The baseline is a RECORDED CLAIM about this tree, so it is refused before it is made: a tree that
|
|
554
|
+
// conceals a change reports CLEAN to `isTreeClean` and then hands the concealed change to the
|
|
555
|
+
// delegate's account at return time. `open` is where that lie is cheapest to catch.
|
|
556
|
+
const honest = hiddenFromPlainDiff(top);
|
|
557
|
+
if (!honest.ok) return refusal('open', `${honest.reason}; nothing was written`);
|
|
558
|
+
const preTreeDigest = uncommittedStateFingerprint(cwd);
|
|
559
|
+
const record = {
|
|
560
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
561
|
+
kind: 'dispatch',
|
|
562
|
+
waveId: need(values, '--wave'),
|
|
563
|
+
nonce: contract.nonce,
|
|
564
|
+
stepClass: contract.stepClass,
|
|
565
|
+
vehicle: { requested: contract.vehicle.requested, selected: contract.vehicle.selected },
|
|
566
|
+
backend: need(values, '--backend'),
|
|
567
|
+
contractDigest: contractDigest(contract),
|
|
568
|
+
preTreeDigest,
|
|
569
|
+
baselineClean,
|
|
570
|
+
deadlineS: contract.deadlineS,
|
|
571
|
+
retryOf: values['--retry-of'] ?? null,
|
|
572
|
+
retryIndex: contract.retry.index,
|
|
573
|
+
retryCap: contract.retry.cap,
|
|
574
|
+
rationale: need(values, '--rationale'),
|
|
575
|
+
timestamp: now(),
|
|
576
|
+
};
|
|
577
|
+
// D3's binding, run as the last act before the append: contractDigest binds the copy, and every
|
|
578
|
+
// mint-time field is compared against the header it was copied from rather than trusted.
|
|
579
|
+
const mint = checkDispatchMintConsistency(contract, record);
|
|
580
|
+
if (!mint.ok) return refusal('open', mint.reason);
|
|
581
|
+
// The placement check is REPEATED here, immediately before the write. A check-then-write pair is
|
|
582
|
+
// not atomic, so this NARROWS the window in which the resolved store could be replaced under us
|
|
583
|
+
// rather than closing it — which is the honest bound of this module's stated posture: it defends
|
|
584
|
+
// against a buggy or interrupted producer, never against a racing adversary.
|
|
585
|
+
const stillPlaced = canonicalStoreRefusal(cwd, env);
|
|
586
|
+
if (stillPlaced !== null) return refusal('open', `${stillPlaced}; nothing was written`);
|
|
587
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
588
|
+
return {
|
|
589
|
+
code: 0,
|
|
590
|
+
stdout: `dispatch open: thread "${record.nonce}" opened in wave "${record.waveId}" — class ${record.stepClass} · backend ${record.backend} · vehicle ${record.vehicle.requested} → ${record.vehicle.selected} · deadline ${record.deadlineS}s (floor ${floorS}s) · retry ${record.retryIndex}/${record.retryCap} · baseline ${baselineClean ? 'CLEAN' : 'DIRTY — the return will be metric-INELIGIBLE (dirty-baseline)'} → ${writtenPath}`,
|
|
591
|
+
stderr: '',
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// ── await: the exec ARRIVAL waiter ────────────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
// The ONLY verb that waits, and the only one that writes nothing at all. Its flags therefore decide
|
|
598
|
+
// no record field and are listed as INPUTS, exactly like `open`'s floor operands.
|
|
599
|
+
export const AWAIT_INPUT_FLAGS = Object.freeze({
|
|
600
|
+
'--nonce': 'the thread whose TERMINAL exec receipt is awaited',
|
|
601
|
+
'--timeout': 'the wait bound in seconds — never above the dispatch\'s REMAINING absolute time',
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
export const EXEC_AWAIT_POLL_MS = 5000;
|
|
605
|
+
|
|
606
|
+
// The wait ended with no terminal receipt. Its OWN status, distinct from a refusal (1) and from
|
|
607
|
+
// usage (2): an expiry is not a malformed input and not a decided outcome — it is the supervision
|
|
608
|
+
// question, and a caller that BRANCHES on the exit code can tell the two apart. It is not a defence
|
|
609
|
+
// against a caller that discards failure wholesale: a blanket `|| true` swallows this exactly as it
|
|
610
|
+
// swallows every other nonzero status.
|
|
611
|
+
export const AWAIT_UNANSWERED_STATUS = 3;
|
|
612
|
+
|
|
613
|
+
// pollExecArrival → { state: 'waiting' | 'satisfied' | 'refused', reason }. Satisfaction is decided
|
|
614
|
+
// POSITIVELY by the receipt reader itself (exec-receipt.mjs): schema, kind and the closed key set
|
|
615
|
+
// must all hold, so nothing that is not an exec receipt can answer an exec dispatch — a review
|
|
616
|
+
// receipt line, a delegation ledger record and a finding manifest each REFUSE here rather than
|
|
617
|
+
// satisfy, which is the D10 rule the review waiter states from its own side. A `reserved` artifact
|
|
618
|
+
// means the run holds the nonce and has published nothing about its end: keep waiting. The read
|
|
619
|
+
// rides the store's own no-follow reader, so the artifact's identity is never resolved through a
|
|
620
|
+
// link and a FIFO can never block the bounded wait.
|
|
621
|
+
export const pollExecArrival = ({ dir, backend, nonce, io = {} }) => {
|
|
622
|
+
const path = join(dir, execReceiptBasename(backend, nonce));
|
|
623
|
+
const read = readRegularFileNoFollow(path, io);
|
|
624
|
+
if (read.outcome === 'absent') {
|
|
625
|
+
return { state: 'waiting', reason: `no exec receipt for {backend "${backend}", nonce "${nonce}"} has been published yet (${path} does not exist)` };
|
|
626
|
+
}
|
|
627
|
+
if (read.outcome === 'foreign') {
|
|
628
|
+
return { state: 'refused', reason: `the exec receipt at ${path} is a ${read.className}, not a regular file — never followed, never read (fail closed)` };
|
|
629
|
+
}
|
|
630
|
+
if (read.outcome === 'error') {
|
|
631
|
+
return { state: 'refused', reason: `the exec receipt at ${path} could not be read (${read.code}) — an unreadable artifact is a FAILED probe, not an absent one (fail closed)` };
|
|
632
|
+
}
|
|
633
|
+
const parsed = parseExecReceipt(read.content);
|
|
634
|
+
if (!parsed.ok) {
|
|
635
|
+
return { state: 'refused', reason: `the artifact at ${path} is REFUSED — ${parsed.reason}; only an exec receipt answers an exec dispatch` };
|
|
636
|
+
}
|
|
637
|
+
const receipt = parsed.receipt;
|
|
638
|
+
if (receipt.backend !== backend || receipt.nonce !== nonce) {
|
|
639
|
+
return { state: 'refused', reason: `the artifact at ${path} names {backend "${receipt.backend}", nonce "${receipt.nonce}"}, not the awaited {backend "${backend}", nonce "${nonce}"} — a receipt is bound to its dispatch by identity, not by the filename it was found under (fail closed)` };
|
|
640
|
+
}
|
|
641
|
+
if (receipt.state === 'reserved') {
|
|
642
|
+
return { state: 'waiting', reason: `the run holds the nonce — a RESERVED receipt at ${path} — but has published no terminal receipt yet` };
|
|
643
|
+
}
|
|
644
|
+
return { state: 'satisfied', reason: `the TERMINAL exec receipt landed (${path}) — outcome ${receipt.outcome} · exit ${receipt.exitStatus} · session ${receipt.sessionId ?? 'none'}` };
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const NO_SLOT_RELEASED = 'NO writer slot was released: a wait that ended without an answer never authorizes the next dispatch (D10 — one in-tree exec dispatch at a time)';
|
|
648
|
+
|
|
649
|
+
// The bound arithmetic is BigInt, and it HAS to be. The frozen record vocabulary admits any positive
|
|
650
|
+
// SAFE INTEGER `deadlineS` (dispatch-record.mjs), so `deadlineS * 1000` leaves the exactly
|
|
651
|
+
// representable range: probed on the pair {deadlineS 9007199254740885, --timeout 9007199254740886},
|
|
652
|
+
// the two products are 1000 ms apart and round to the SAME double, so the one refusal this
|
|
653
|
+
// arithmetic exists to make — a timeout reaching past the deadline — was skipped and the wait then
|
|
654
|
+
// polled against a bound 9e18 ms away, which is not a bound at all. Only the sleep interval, already
|
|
655
|
+
// clamped to `pollMs`, ever becomes a Number.
|
|
656
|
+
const MAX_REPRESENTABLE_MS = 8640000000000000n;
|
|
657
|
+
|
|
658
|
+
// An instant a Date cannot hold is STATED, never thrown: the promised unanswered status is the
|
|
659
|
+
// message's whole point, and a RangeError inside it would return a refusal instead.
|
|
660
|
+
const instantAt = (ms) => (ms >= -MAX_REPRESENTABLE_MS && ms <= MAX_REPRESENTABLE_MS
|
|
661
|
+
? new Date(Number(ms)).toISOString()
|
|
662
|
+
: `${ms} ms after the epoch — beyond the range a date can represent`);
|
|
663
|
+
|
|
664
|
+
const ceilSeconds = (ms) => (ms + 999n) / 1000n;
|
|
665
|
+
|
|
666
|
+
// How the thread was closed, in the words the ledger's own kinds give: a return names its outcome,
|
|
667
|
+
// a fold and a degrade name themselves.
|
|
668
|
+
const closureLabel = (last) => (last.kind === 'return' ? `its ${last.outcome} return` : `its ${last.kind}`);
|
|
669
|
+
|
|
670
|
+
const runAwait = async ({ baseCwd, env, argv, now, sleep, pollMs }) => {
|
|
671
|
+
const { values, operands, cwd } = scan(argv, AWAIT_INPUT_FLAGS, baseCwd);
|
|
672
|
+
refuseOperands('await', operands);
|
|
673
|
+
const nonce = need(values, '--nonce');
|
|
674
|
+
const timeoutS = values['--timeout'] === undefined ? null : asPositiveInteger('--timeout', values['--timeout']);
|
|
675
|
+
// ONE clock per run: the same `now` every record in this module is stamped from, read as an
|
|
676
|
+
// instant. The deadline is ABSOLUTE — it is measured from the DISPATCH record's timestamp, never
|
|
677
|
+
// from whenever this wait happened to start — so the waiter needs a wall clock, not an elapsed one.
|
|
678
|
+
const nowMs = () => {
|
|
679
|
+
const ms = Date.parse(now());
|
|
680
|
+
if (!Number.isFinite(ms)) throw usageFail('the injected clock did not produce an instant — a wait bounded by an unreadable clock is not bounded at all (fail closed)');
|
|
681
|
+
return BigInt(ms);
|
|
682
|
+
};
|
|
683
|
+
const ledger = readLegalLedger(cwd, env);
|
|
684
|
+
if (!ledger.ok) return refusal('await', ledger.reason);
|
|
685
|
+
const state = delegationThreadState(ledger.records, nonce);
|
|
686
|
+
if (state.dispatch === null) {
|
|
687
|
+
return refusal('await', `no dispatch for nonce "${nonce}" is in the store — a wait watches for the answer to a thread that was opened, and there is nothing here to answer`);
|
|
688
|
+
}
|
|
689
|
+
const dispatch = state.dispatch;
|
|
690
|
+
const deadlineAt = BigInt(Date.parse(dispatch.timestamp)) + BigInt(dispatch.deadlineS) * 1000n;
|
|
691
|
+
const started = nowMs();
|
|
692
|
+
const probe = { dir: ledger.dir, backend: dispatch.backend, nonce };
|
|
693
|
+
const answerFor = (p) => {
|
|
694
|
+
if (p.state === 'satisfied') return { code: 0, stdout: `dispatch await: ARRIVED — ${p.reason}`, stderr: '' };
|
|
695
|
+
return p.state === 'refused' ? refusal('await', p.reason) : null;
|
|
696
|
+
};
|
|
697
|
+
// ARRIVAL is read FIRST, before EVERY bound — the two wait bounds below AND the `--timeout`
|
|
698
|
+
// admissibility check. The exec receipt carries its own timestamp and the absorb door refuses a
|
|
699
|
+
// LATE one by name, so lateness has exactly ONE decision site and it is `return`; a receipt already
|
|
700
|
+
// on disk is a fact this verb reports, never a clock question it re-decides. (The review waiter
|
|
701
|
+
// checks its deadline first for the opposite reason: a receipt LINE carries no dispatch-bound
|
|
702
|
+
// timestamp at all, so there the clock is the only evidence there is.)
|
|
703
|
+
let poll = pollExecArrival(probe);
|
|
704
|
+
const opening = answerFor(poll);
|
|
705
|
+
if (opening !== null) return opening;
|
|
706
|
+
// A CLOSED thread is never awaited: nothing can answer it any more, and the expiry message would
|
|
707
|
+
// send the operator to close what is already closed. It is checked HERE — in the waiting branch of
|
|
708
|
+
// the first poll, after arrival — because a terminal receipt still on disk is a fact this verb
|
|
709
|
+
// reports whatever the ledger says, while a `--no-receipt` absorb leaves its RESERVATION behind and
|
|
710
|
+
// a degrade may leave no artifact at all: both would otherwise wait out the whole bound.
|
|
711
|
+
if (state.terminal) {
|
|
712
|
+
return refusal('await', `thread "${nonce}" is already CLOSED by ${closureLabel(state.last)} — nothing can answer it any more, and ${poll.reason}; a closed thread is never awaited`);
|
|
713
|
+
}
|
|
714
|
+
// The `--timeout` bound may never reach PAST the absolute deadline — a longer wait would report a
|
|
715
|
+
// still-running dispatch where the ledger already says the thread is over. It is CLAMPED by nothing
|
|
716
|
+
// and refused instead: a silently shortened wait would let a caller believe they waited longer than
|
|
717
|
+
// they did. It bounds a WAIT, so it is checked only once there IS one — nothing on disk, and the
|
|
718
|
+
// dispatch not already expired (an expired one has no wait to bound and is answered below).
|
|
719
|
+
if (timeoutS !== null && started < deadlineAt && BigInt(timeoutS) * 1000n > deadlineAt - started) {
|
|
720
|
+
return refusal('await', `--timeout ${timeoutS}s reaches past this dispatch's ABSOLUTE deadline (${dispatch.timestamp} + ${dispatch.deadlineS}s = ${instantAt(deadlineAt)}), of which ${ceilSeconds(deadlineAt - started)}s remain — the deadline is measured from the dispatch record, so waiting beyond it would watch a thread the ledger already calls over; nothing was waited on`);
|
|
721
|
+
}
|
|
722
|
+
const waitEndsAt = timeoutS === null ? deadlineAt : started + BigInt(timeoutS) * 1000n;
|
|
723
|
+
for (;;) {
|
|
724
|
+
// Each pass consults the clock over the poll that has ALREADY happened, then sleeps and polls
|
|
725
|
+
// again — so a receipt landing during the last sleep is still reported rather than lost to a
|
|
726
|
+
// cutoff that fires a moment later.
|
|
727
|
+
const at = nowMs();
|
|
728
|
+
if (at >= deadlineAt) {
|
|
729
|
+
return {
|
|
730
|
+
code: AWAIT_UNANSWERED_STATUS,
|
|
731
|
+
stdout: '',
|
|
732
|
+
stderr: `dispatch await: EXPIRED — this dispatch's ABSOLUTE deadline (${dispatch.timestamp} + ${dispatch.deadlineS}s = ${instantAt(deadlineAt)}) passed and ${poll.reason}. This is a SUPERVISION question, not an outcome: establish whether the run is still alive, was killed, or died without publishing — join or reap it — then close the thread with return --no-receipt or degrade. ${NO_SLOT_RELEASED}`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (at >= waitEndsAt) {
|
|
736
|
+
return {
|
|
737
|
+
code: AWAIT_UNANSWERED_STATUS,
|
|
738
|
+
stdout: '',
|
|
739
|
+
stderr: `dispatch await: TIMEOUT after ${timeoutS}s — ${poll.reason}, and this dispatch is still INSIDE its absolute deadline (${ceilSeconds(deadlineAt - at)}s remain until ${instantAt(deadlineAt)}). The wait ended, the dispatch did not: wait again, or supervise it. ${NO_SLOT_RELEASED}`,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
// The ONLY conversion back to Number, and it happens after the interval is already clamped:
|
|
743
|
+
// whichever bound is nearer, a remaining span wider than one poll interval sleeps exactly one.
|
|
744
|
+
const nearer = waitEndsAt < deadlineAt ? waitEndsAt : deadlineAt;
|
|
745
|
+
const remaining = nearer - at;
|
|
746
|
+
await sleep(remaining > BigInt(pollMs) ? pollMs : Number(remaining));
|
|
747
|
+
poll = pollExecArrival(probe);
|
|
748
|
+
const answered = answerFor(poll);
|
|
749
|
+
if (answered !== null) return answered;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// ── return: the wrapper's exec receipt, ABSORBED ──────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
// The return-time guard the Phase-1 council required. `git diff` SKIPS index entries carrying
|
|
756
|
+
// assume-unchanged or skip-worktree and honours diff.ignoreSubmodules, so a path can be CHANGED and
|
|
757
|
+
// invisible to BOTH halves of the ratio at once — the producer's enumeration and the canonical
|
|
758
|
+
// payload the denominator is framed from run the same plain probes. The kit already owns a probe
|
|
759
|
+
// that sees them (computeWorkingState forces --ignore-submodules=none and folds in flaggedIndexLag),
|
|
760
|
+
// so the absorb door compares the two views and refuses the DIFFERENCE by name.
|
|
761
|
+
//
|
|
762
|
+
// It is deliberately NOT a producer fix: D7 binds the numerator to computeFingerprintPayload's byte
|
|
763
|
+
// domain, and forcing the flags on one side only would let the numerator count objects the
|
|
764
|
+
// denominator cannot see — a worse failure than the blindness. The payload-side fix is queued as its
|
|
765
|
+
// own frozen-shared-surface change.
|
|
766
|
+
//
|
|
767
|
+
// The untracked section is excluded from the comparison because BOTH views read it with the same
|
|
768
|
+
// `ls-files --others --exclude-standard` probe: there is no divergence there to find. Both sides
|
|
769
|
+
// also decode path bytes identically, so an exotic name cannot make the two disagree by decoding
|
|
770
|
+
// alone — and if it ever did, the answer would be a refusal, never a wrong number.
|
|
771
|
+
// A tree can LIE about itself in two independent ways, and neither is decidable by comparing two
|
|
772
|
+
// views that share the same blindness. So this asks two questions of its own, and every door that
|
|
773
|
+
// measures or binds a tree (`open`, `return`, `fold`) asks them before it does anything else.
|
|
774
|
+
//
|
|
775
|
+
// (a) INDEX BITS, fail closed on their PRESENCE. `git ls-files -v` tags an assume-unchanged entry
|
|
776
|
+
// lowercase and a skip-worktree entry `S`, and the second one is the sharp case: a MISSING
|
|
777
|
+
// skip-worktree path is an ordinary sparse checkout to every probe this kit owns — probed live, the
|
|
778
|
+
// deletion of a materialized skip-worktree file is invisible to `computeWorkingState`, to the plain
|
|
779
|
+
// diff, to the tree fingerprint AND to the producer's enumeration. Nothing can compare its way to
|
|
780
|
+
// that, so the BIT is refused rather than its effect. It bites hardest at `open`: set the bit, delete
|
|
781
|
+
// the file, open (a FALSE clean baseline is recorded), then clear the bit — and the return credits the
|
|
782
|
+
// delegate with a deletion it never made. Refusing any tag but `H` also refuses an unmerged index,
|
|
783
|
+
// which is honest for a measurement.
|
|
784
|
+
//
|
|
785
|
+
// (b) The --ignore-submodules axis, PER SIDE and on BYTES. Against the UNION of the two name lists a
|
|
786
|
+
// path visible on one side masks its own hidden state on the other; against DECODED names two
|
|
787
|
+
// distinct paths collapse into one (`h\xff.txt` and a real `h�.txt` decode identically, so one
|
|
788
|
+
// hidden path hides behind one visible path). Segments therefore stay raw and only a reversible
|
|
789
|
+
// latin1 key indexes the set. The staged side needs only this axis: probed live, `git diff --cached`
|
|
790
|
+
// lists assume-unchanged and skip-worktree entries unchanged, because those bits gate the WORKTREE
|
|
791
|
+
// comparison and the index-vs-HEAD comparison never consults them.
|
|
792
|
+
//
|
|
793
|
+
// Exported as a TEST SEAM as well: the fail-closed arms guard against a git that cannot answer at
|
|
794
|
+
// all, which no fixture can produce from inside a healthy repository.
|
|
795
|
+
|
|
796
|
+
// canonicalizeExisting(path) → { ok, real } | { ok: false, code, at }. Resolves through symlinks by
|
|
797
|
+
// walking up to the nearest EXISTING ancestor and re-joining the lexical tail, so a directory that
|
|
798
|
+
// has not been created yet is still judged. ENOENT is the ONLY error that walks: every other errno
|
|
799
|
+
// (EACCES, EIO, ELOOP) fails closed, because a path this kit cannot resolve is not a path it may
|
|
800
|
+
// approve.
|
|
801
|
+
const canonicalizeExisting = (start) => {
|
|
802
|
+
let current = resolve(start);
|
|
803
|
+
const tail = [];
|
|
804
|
+
for (;;) {
|
|
805
|
+
try {
|
|
806
|
+
const real = realpathSync(current);
|
|
807
|
+
return { ok: true, real: tail.length === 0 ? real : join(real, ...[...tail].reverse()) };
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const code = (err && err.code) || (err && err.message) || 'realpath failed';
|
|
810
|
+
if (code !== 'ENOENT') return { ok: false, code, at: current };
|
|
811
|
+
const parent = dirname(current);
|
|
812
|
+
if (parent === current) return { ok: false, code: 'no existing ancestor', at: current };
|
|
813
|
+
tail.push(basename(current));
|
|
814
|
+
current = parent;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// WHICH LEDGER the three tree-binding verbs may use: the CANONICAL one, exactly —
|
|
820
|
+
// `<git common dir>/agent-workflow-delegation.jsonl`. The `AW_DELEGATION_STORE` seam takes any
|
|
821
|
+
// absolute path, which is right for a reader and wrong for a writer that MEASURES the tree it writes
|
|
822
|
+
// into. Three separate failures collapse into this one equality:
|
|
823
|
+
// • a store inside the work tree is carried by the payload AND enumerated as an object, so the
|
|
824
|
+
// metric counts its own bookkeeping as delegated work — and the append that follows
|
|
825
|
+
// `postTreeDigest` moves the tree, so every later fold drifts by construction (probed);
|
|
826
|
+
// • a store belonging to ANOTHER repository would measure this tree against a foreign thread,
|
|
827
|
+
// since the frozen record schema binds no worktree identity;
|
|
828
|
+
// • two ledgers in ONE git dir would share artifact names, which are a function of
|
|
829
|
+
// {backend, nonce} alone — so a return could absorb the neighbouring ledger's receipt.
|
|
830
|
+
// Containment inside the git dir closed the first two and NOT the third; equality closes all three
|
|
831
|
+
// and removes a rule rather than adding one. One repository, one delegation ledger — which is what
|
|
832
|
+
// resolving to the git COMMON dir already meant. `register`, `observe` and `aggregate` keep the
|
|
833
|
+
// unrestricted override: they bind no tree.
|
|
834
|
+
//
|
|
835
|
+
// STATED RESIDUAL, and it is a real one: two LINKED WORKTREES of the same repository share that one
|
|
836
|
+
// canonical ledger by design, so this rule cannot separate them. Opening in one linked worktree and
|
|
837
|
+
// returning or folding in another measures the wrong tree, and the frozen record schema carries no
|
|
838
|
+
// worktree identity to bind it with (`preTreeDigest` is copied, not recomputed; the nonce is opaque).
|
|
839
|
+
// That case is FORBIDDEN by the D10 one-writer bar and is named here rather than mechanized.
|
|
840
|
+
const canonicalStoreRefusal = (cwd, env) => {
|
|
841
|
+
const storePath = resolveDelegationStorePath(cwd, env);
|
|
842
|
+
if (storePath === null) return null; // the "no work tree" refusal belongs to the caller, by name
|
|
843
|
+
const commonDir = gitLine(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
844
|
+
if (commonDir === null) return 'the git common dir could not be resolved — a measured dispatch is never written to a ledger this kit cannot place (fail closed)';
|
|
845
|
+
const store = canonicalizeExisting(dirname(storePath));
|
|
846
|
+
const home = canonicalizeExisting(commonDir);
|
|
847
|
+
// FAIL CLOSED on an unresolvable path, never lexically. The lexical fallback belongs to
|
|
848
|
+
// `isEntryPoint`, where an unresolvable side can only make a comparison FAIL; here it would make a
|
|
849
|
+
// check PASS — a store lexically inside the git dir but really outside it (an escaping symlink)
|
|
850
|
+
// would be admitted the moment a transient EACCES/EIO hid its real location. Same construct,
|
|
851
|
+
// opposite polarity, opposite safety.
|
|
852
|
+
for (const probed of [store, home]) {
|
|
853
|
+
if (!probed.ok) return `the path ${probed.at} could not be canonicalized (${probed.code}) — a store this kit cannot place is never bound to a tree (fail closed)`;
|
|
854
|
+
}
|
|
855
|
+
if (store.real !== home.real || basename(storePath) !== DELEGATION_STORE_BASENAME) {
|
|
856
|
+
return `the delegation store resolves to ${storePath}, which is not this repository's CANONICAL ledger (${join(home.real, DELEGATION_STORE_BASENAME)}) — a tree-binding verb uses that one and no other: a store inside the work tree is measured as part of the change set it is supposed to be measuring, a store in another repository would measure this tree against a foreign thread, and a SECOND ledger in this git dir would share artifact names with the first, since they are a function of {backend, nonce} alone; drop the AW_DELEGATION_STORE override (register, observe and aggregate still honour it)`;
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// A symlink's TARGET is read by the shared payload with `readlink` as a STRING, so bytes that are not
|
|
862
|
+
// valid UTF-8 are gone before any framing question arises — probed, two links whose targets are the
|
|
863
|
+
// single bytes 0xff and 0xfe produce the SAME payload and the SAME enumeration, so swapping one for
|
|
864
|
+
// the other after a return is invisible to the fold. Until the payload reads targets as bytes (queued
|
|
865
|
+
// with the framing migration), such a link is refused: this is not a fourth blind CLASS, it is the
|
|
866
|
+
// narrow case where the payload provably cannot follow one object's bytes.
|
|
867
|
+
// ENOENT is the ONLY error that means "there is nothing to check here" — a deleted link, whose bytes
|
|
868
|
+
// ride the diff exactly. EVERY other errno refuses, EINVAL emphatically included: the producer labels
|
|
869
|
+
// an object `symlink` when ANY layer carries mode 120000, so a committed symlink REPLACED by a binary
|
|
870
|
+
// regular file is a `symlink` entry whose readlink answers EINVAL — and skipping it would walk that
|
|
871
|
+
// object past the content-blind refusal as well, which never sees a `binary` kind that was never
|
|
872
|
+
// emitted. Two guards, and the type change falls between them unless this one fails closed.
|
|
873
|
+
// `io.readlink` is a TEST SEAM for the errno arms a fixture cannot produce on demand.
|
|
874
|
+
const symlinkTargetRefusal = (top, entries, io = {}) => {
|
|
875
|
+
const readlink = io.readlink ?? ((path) => readlinkSync(path, { encoding: 'buffer' }));
|
|
876
|
+
const lost = [];
|
|
877
|
+
for (const entry of entries) {
|
|
878
|
+
if (entry.kind !== 'symlink') continue;
|
|
879
|
+
let target;
|
|
880
|
+
try {
|
|
881
|
+
target = readlink(join(top, entry.path));
|
|
882
|
+
} catch (err) {
|
|
883
|
+
const code = (err && err.code) || (err && err.message) || 'readlink failed';
|
|
884
|
+
if (code === 'ENOENT') continue;
|
|
885
|
+
return `the symlink "${entry.path}" could not be read (${code}) — an unreadable link is a FAILED probe, not an absent one, and a path the producer labelled a symlink while readlink refuses it is a TYPE CHANGE whose new bytes no guard here can see (fail closed)`;
|
|
886
|
+
}
|
|
887
|
+
if (!Buffer.from(target.toString('utf8'), 'utf8').equals(target)) lost.push(`${entry.path} -> 0x${target.toString('hex')}`);
|
|
888
|
+
}
|
|
889
|
+
if (lost.length === 0) return null;
|
|
890
|
+
return `${lost.length} symlink target(s) are not valid UTF-8 (${lost.join(', ')}) — the shared payload reads a target as a STRING, so those bytes are folded to U+FFFD before the digest is taken and a later change of target moves nothing at all; this lane is fail-closed for them until the payload reads targets as bytes`;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
// NUL-delimited git output, kept as BUFFER segments — a decoded split cannot be undone.
|
|
894
|
+
const zSegments = (buf) => {
|
|
895
|
+
const out = [];
|
|
896
|
+
let start = 0;
|
|
897
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
898
|
+
if (buf[i] !== 0) continue;
|
|
899
|
+
if (i > start) out.push(buf.subarray(start, i));
|
|
900
|
+
start = i + 1;
|
|
901
|
+
}
|
|
902
|
+
if (start < buf.length) out.push(buf.subarray(start));
|
|
903
|
+
return out;
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// A path is SHOWN as text only where its bytes are exactly that text; otherwise as hex. A message
|
|
907
|
+
// that echoed raw invalid bytes would be one more place a path loses its identity.
|
|
908
|
+
const showPath = (bytes) => {
|
|
909
|
+
const text = bytes.toString('utf8');
|
|
910
|
+
return Buffer.from(text, 'utf8').equals(bytes) ? text : `<non-UTF-8 path 0x${bytes.toString('hex')}>`;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const LS_FILES_CACHED_TAG = 'H';
|
|
914
|
+
|
|
915
|
+
export const hiddenFromPlainDiff = (top) => {
|
|
916
|
+
const probe = (args) => {
|
|
917
|
+
const buf = gitBuf(args, top);
|
|
918
|
+
return buf == null ? null : zSegments(buf);
|
|
919
|
+
};
|
|
920
|
+
const indexEntries = probe(['ls-files', '-v', '-z']);
|
|
921
|
+
const plainStaged = probe(['diff', '--cached', '--name-only', '-z', '--no-ext-diff']);
|
|
922
|
+
const plainUnstaged = probe(['diff', '--name-only', '-z', '--no-ext-diff']);
|
|
923
|
+
const forcedStaged = probe(['diff', '--cached', '--name-only', '-z', '--no-ext-diff', '--ignore-submodules=none']);
|
|
924
|
+
const forcedUnstaged = probe(['diff', '--name-only', '-z', '--no-ext-diff', '--ignore-submodules=none']);
|
|
925
|
+
if ([indexEntries, plainStaged, plainUnstaged, forcedStaged, forcedUnstaged].some((r) => r === null)) {
|
|
926
|
+
return { ok: false, reason: 'a git probe of the change set could not be read — a tree this kit cannot describe is never measured (fail closed)' };
|
|
927
|
+
}
|
|
928
|
+
// `<tag><space><path>` per segment; the tag is one ASCII byte by the format's own grammar.
|
|
929
|
+
const flagged = indexEntries
|
|
930
|
+
.filter((seg) => seg.length > 2 && String.fromCharCode(seg[0]) !== LS_FILES_CACHED_TAG)
|
|
931
|
+
.map((seg) => `${String.fromCharCode(seg[0])} ${showPath(seg.subarray(2))}`);
|
|
932
|
+
if (flagged.length > 0) {
|
|
933
|
+
return { ok: false, reason: `${flagged.length} index entr(ies) carry a tag other than "${LS_FILES_CACHED_TAG}" (${flagged.join('; ')}) — an assume-unchanged or skip-worktree entry makes the index lie about the worktree, and the sharpest case is invisible to EVERY probe this kit owns: deleting a materialized skip-worktree file changes no diff, no fingerprint and no enumeration, so a delegated measurement over such a tree is not honest at all; clear the bits (git update-index --no-assume-unchanged / --no-skip-worktree) and try again` };
|
|
934
|
+
}
|
|
935
|
+
const key = (seg) => seg.toString('latin1');
|
|
936
|
+
const missing = (forced, plain) => {
|
|
937
|
+
const seen = new Set(plain.map(key));
|
|
938
|
+
return forced.filter((seg) => !seen.has(key(seg)));
|
|
939
|
+
};
|
|
940
|
+
const hiddenStaged = missing(forcedStaged, plainStaged);
|
|
941
|
+
const hiddenUnstaged = missing(forcedUnstaged, plainUnstaged);
|
|
942
|
+
const count = hiddenStaged.length + hiddenUnstaged.length;
|
|
943
|
+
if (count === 0) return { ok: true };
|
|
944
|
+
// A path hidden on BOTH sides is two facts, not one — the index lies and the worktree lies — so it
|
|
945
|
+
// is counted and named twice, under the side that hid it.
|
|
946
|
+
const where = [
|
|
947
|
+
hiddenStaged.length > 0 ? `staged: ${hiddenStaged.map(showPath).join(', ')}` : null,
|
|
948
|
+
hiddenUnstaged.length > 0 ? `unstaged: ${hiddenUnstaged.map(showPath).join(', ')}` : null,
|
|
949
|
+
].filter((part) => part !== null).join('; ');
|
|
950
|
+
return { ok: false, reason: `${count} changed path(s) are HIDDEN from the plain git diff this metric is computed over (${where}) — an ignore-submodules setting keeps them out of BOTH the numerator and the denominator, so the recorded bytes would describe a change set that is not the one on disk; clear the diff config and try again` };
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// THE CONTENT-BLIND CLASSES, and the line between them and the merely ambiguous ones.
|
|
954
|
+
//
|
|
955
|
+
// For three kinds the payload carries NO CONTENT AT ALL, so their bytes can move underneath a digest
|
|
956
|
+
// that cannot follow them — all three probed live:
|
|
957
|
+
// • `binary` — the payload holds `untracked-binary:<path>`, and a tracked binary's diff is the one
|
|
958
|
+
// line "Binary files … differ". Neither a same-size mutation nor a SIZE change moves the digest.
|
|
959
|
+
// • `non-regular` — `untracked-nonregular:<path>`, a name and nothing else.
|
|
960
|
+
// • `submodule` — the first transition to dirty moves the digest (the superproject line gains
|
|
961
|
+
// "-dirty") and NOTHING after it does: a second, different nested edit and a whole new nested
|
|
962
|
+
// file both leave the digest unchanged.
|
|
963
|
+
// For those, `fold`'s binding would accept bytes nobody returned while the numerator counts a size
|
|
964
|
+
// re-read at return time. Documenting that would leave a promise knowingly false, so the capability
|
|
965
|
+
// is SUBTRACTED: a change set carrying one is refused, by name, at both doors.
|
|
966
|
+
//
|
|
967
|
+
// Regular-file content and a symlink's TARGET are deliberately NOT here. They are present in the
|
|
968
|
+
// payload — only unframed, so adjacent entries can alias each other (the residual named in the mode
|
|
969
|
+
// doc and in --help). Subtracting them would refuse `new` and `symlink`, which is every delegated
|
|
970
|
+
// change set there is, and the lane would measure nothing at all.
|
|
971
|
+
//
|
|
972
|
+
// Restoring the three means changing `computeFingerprintPayload` — a Plan-1 frozen surface the
|
|
973
|
+
// REVIEW lane also binds — which is queued, not done here.
|
|
974
|
+
export const CONTENT_BLIND_KINDS = Object.freeze(['binary', 'non-regular', 'submodule']);
|
|
975
|
+
|
|
976
|
+
// Per class, and precisely: the submodule arm is CONSERVATIVE, not a claim about every submodule. A
|
|
977
|
+
// clean staged gitlink replacement carries exact OIDs in the payload; it is the nested state of an
|
|
978
|
+
// already-DIRTY one that becomes invisible, and no cheap probe separates the two at this door.
|
|
979
|
+
const CONTENT_BLIND_WHY = {
|
|
980
|
+
binary: 'the payload holds its name only, so neither its content nor its size reaches the digest',
|
|
981
|
+
'non-regular': 'the payload holds its name only',
|
|
982
|
+
submodule: 'refused conservatively: a clean pointer change does carry its OIDs, but once the submodule is dirty the payload records nothing further about its nested state',
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
export const contentBlindRefusal = (entries) => {
|
|
986
|
+
const blind = entries.filter((e) => CONTENT_BLIND_KINDS.includes(e.kind));
|
|
987
|
+
if (blind.length === 0) return null;
|
|
988
|
+
const listed = blind.map((e) => `${e.kind} ${e.path} (${CONTENT_BLIND_WHY[e.kind]})`).join('; ');
|
|
989
|
+
return `the change set carries ${blind.length} object(s) whose CONTENT never enters the uncommitted-state payload (${listed}) — their bytes can move under a tree digest that cannot follow them, so the numerator would count a size the fold's binding cannot re-confirm. This lane is fail-closed for them until the shared payload can carry their content (a frozen surface the review lane binds too): keep such objects out of a measured dispatch, or close the thread with degrade`;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// Pre-spend: neither artifact name may be taken before the dispatch that will own it exists. The
|
|
993
|
+
// names are a function of {backend, nonce} alone, so a leftover pair — from a rotated ledger, a
|
|
994
|
+
// hand-run wrapper, an interrupted cycle — would be absorbed later as this thread's own evidence.
|
|
995
|
+
const existingArtifactRefusal = (dir, backend, nonce) => {
|
|
996
|
+
const taken = [execReceiptBasename(backend, nonce), execReportBasename(backend, nonce)]
|
|
997
|
+
.filter((name) => name !== null && lstatNoFollowRead(join(dir, name)) !== null);
|
|
998
|
+
if (taken.length === 0) return null;
|
|
999
|
+
return `${taken.length} exec artifact(s) for {backend "${backend}", nonce "${nonce}"} already exist beside the ledger (${taken.join(', ')}) — those names are a function of that pair ALONE, so whatever wrote them would be absorbed as this dispatch's own evidence; remove them, or mint the sub-task under a fresh nonce`;
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// The two artifacts the wrapper minted, read through the store's own no-follow reader.
|
|
1003
|
+
const readReceiptArtifact = (dir, backend, nonce) => {
|
|
1004
|
+
// Neither token can be unsafe here: the record vocabulary pins BOTH to the shared safe grammar and
|
|
1005
|
+
// the ledger audit re-established that every record in the store passed it, so the basename is
|
|
1006
|
+
// always buildable at this point.
|
|
1007
|
+
const path = join(dir, execReceiptBasename(backend, nonce));
|
|
1008
|
+
const read = readRegularFileNoFollow(path);
|
|
1009
|
+
if (read.outcome === 'absent') {
|
|
1010
|
+
// NOT a --no-receipt recovery: that lane reads THIS path, so it would refuse identically. With no
|
|
1011
|
+
// artifact at all the wrapper proved nothing about the run — not even that it started — and the
|
|
1012
|
+
// only honest closure is a recorded degrade.
|
|
1013
|
+
return { ok: false, reason: `no exec receipt at ${path} — a return is built from the artifact the wrapper minted, never from a tree alone, and with no reservation there either nothing about this run is knowable (--no-receipt reads the same path and would refuse the same way); close the thread with degrade` };
|
|
1014
|
+
}
|
|
1015
|
+
if (read.outcome === 'foreign') return { ok: false, reason: `the exec receipt at ${path} is a ${read.className}, not a regular file — refusing to read it (fail closed)` };
|
|
1016
|
+
if (read.outcome === 'error') return { ok: false, reason: `the exec receipt at ${path} could not be read (${read.code}) — fail closed` };
|
|
1017
|
+
const parsed = parseExecReceipt(read.content);
|
|
1018
|
+
return parsed.ok ? { ok: true, receipt: parsed.receipt, path } : { ok: false, reason: `the exec receipt at ${path} is REFUSED — ${parsed.reason}` };
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const readReportArtifact = (dir, backend, nonce) => {
|
|
1022
|
+
const path = join(dir, execReportBasename(backend, nonce));
|
|
1023
|
+
const read = readFileBytesNoFollow(path);
|
|
1024
|
+
if (read.outcome === 'absent') return { ok: true, path, bytes: null };
|
|
1025
|
+
if (read.outcome === 'foreign') return { ok: false, reason: `the exec report at ${path} is a ${read.className}, not a regular file — refusing to read it (fail closed)` };
|
|
1026
|
+
// ABSENT and UNREADABLE are different answers, the same distinction the producer draws: an absent
|
|
1027
|
+
// report is a lane (report-if-present, D5), an unreadable one is a failed probe.
|
|
1028
|
+
if (read.outcome === 'error') return { ok: false, reason: `the exec report at ${path} could not be read (${read.code}) — an unreadable report is a FAILED probe, not an absent one (fail closed)` };
|
|
1029
|
+
return { ok: true, path, bytes: read.bytes };
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const EMPTY_REPORT = Buffer.alloc(0);
|
|
1033
|
+
|
|
1034
|
+
// treeDriftRefusal(opening, closing) → the reason, or null. Exported as a TEST SEAM, and only for
|
|
1035
|
+
// that: a tree that moves BETWEEN two git reads inside one call cannot be produced on demand from a
|
|
1036
|
+
// fixture, so the arm that catches it is pinned directly rather than left as unreachable prose.
|
|
1037
|
+
export const treeDriftRefusal = (openingDigest, closingDigest) => (openingDigest === closingDigest
|
|
1038
|
+
? null
|
|
1039
|
+
: `the tree moved WHILE the return was being computed (${openingDigest.slice(0, 12)}… → ${closingDigest.slice(0, 12)}…) — the enumeration and the diff would then describe two different change sets, and the record would bind a postTreeDigest neither of them saw; nothing was written, so leave the tree alone and return again`);
|
|
1040
|
+
|
|
1041
|
+
const runReturn = ({ baseCwd, env, argv, now }) => {
|
|
1042
|
+
const { values, operands, cwd } = scan(argv, { ...RETURN_FLAG_FIELDS, ...RETURN_INPUT_FLAGS }, baseCwd, { booleans: RETURN_BOOLEAN_FLAGS });
|
|
1043
|
+
refuseOperands('return', operands);
|
|
1044
|
+
const nonce = need(values, '--nonce');
|
|
1045
|
+
const noReceipt = values['--no-receipt'] === true;
|
|
1046
|
+
// The exit status is the RECEIPT's fact wherever a receipt exists — a hand-typed one beside it
|
|
1047
|
+
// would let the ledger disagree with the run it claims to record.
|
|
1048
|
+
if (!noReceipt && values['--exit-status'] !== undefined) {
|
|
1049
|
+
throw usageFail('--exit-status belongs to the --no-receipt lane — a terminal receipt already records the status the run actually exited with');
|
|
1050
|
+
}
|
|
1051
|
+
// The preconditions read in order: a repository, then a ledger this verb is ALLOWED to bind a tree
|
|
1052
|
+
// against, then a legal ledger, then the thread, then the artifacts, then the tree itself.
|
|
1053
|
+
const top = resolveRepoRoot(cwd);
|
|
1054
|
+
if (top === null) {
|
|
1055
|
+
return refusal('return', 'not inside a git work tree — a return is enumerated against a repository (fail closed)');
|
|
1056
|
+
}
|
|
1057
|
+
const placed = canonicalStoreRefusal(cwd, env);
|
|
1058
|
+
if (placed !== null) return refusal('return', `${placed}; nothing was written`);
|
|
1059
|
+
const ledger = readLegalLedger(cwd, env);
|
|
1060
|
+
if (!ledger.ok) return refusal('return', ledger.reason);
|
|
1061
|
+
const state = delegationThreadState(ledger.records, nonce);
|
|
1062
|
+
if (state.dispatch === null) {
|
|
1063
|
+
return refusal('return', `no dispatch for nonce "${nonce}" is in the store — a return answers the dispatch that opened its thread, and a record that binds to nothing is never absorbed; nothing was written`);
|
|
1064
|
+
}
|
|
1065
|
+
const dispatch = state.dispatch;
|
|
1066
|
+
const artifact = readReceiptArtifact(ledger.dir, dispatch.backend, nonce);
|
|
1067
|
+
if (!artifact.ok) return refusal('return', artifact.reason);
|
|
1068
|
+
const receipt = artifact.receipt;
|
|
1069
|
+
// The terminal-exit rule AT THE ABSORB DOOR: a reservation says the run was minted, never that it
|
|
1070
|
+
// finished. `await` treats the same artifact as "keep waiting"; here it is a SUPERVISION question,
|
|
1071
|
+
// because absorbing it would record an outcome nobody observed.
|
|
1072
|
+
if (!noReceipt && receipt.state === 'reserved') {
|
|
1073
|
+
return refusal('return', `the exec receipt at ${artifact.path} is still RESERVED — the run holds the nonce but has published no terminal receipt, so nothing is known about how it ended. This is a SUPERVISION question, not a timeout: wait for the run (dispatch await), or, once you have established what happened to it, absorb the reservation with --no-receipt --exit-status <n> --outcome <o>; nothing was written`);
|
|
1074
|
+
}
|
|
1075
|
+
// …and the same rule from the other side. `--no-receipt` absorbs a RESERVATION; over a TERMINAL
|
|
1076
|
+
// artifact it would let a hand-stated outcome and exit status overwrite ones the run actually
|
|
1077
|
+
// proved, skip the report verification entirely, and null a real session id — a recorded lie built
|
|
1078
|
+
// out of a recovery lane. The artifact decides which lane applies, not the caller.
|
|
1079
|
+
if (noReceipt && receipt.state === 'terminal') {
|
|
1080
|
+
return refusal('return', `--no-receipt absorbs a RESERVATION, and the artifact at ${artifact.path} is TERMINAL: the run published its exit status, its session id and its report digest, so a hand-stated outcome here would discard proven facts and skip the report check. Absorb it on the ordinary lane (drop --no-receipt); if the terminal receipt itself is not to be trusted, close the thread with degrade instead; nothing was written`);
|
|
1081
|
+
}
|
|
1082
|
+
// {backend, nonce} live in the artifact's NAME and in its BODY. A body disagreeing with the name
|
|
1083
|
+
// is a receipt minted for another dispatch, and the correlation would otherwise pass on the
|
|
1084
|
+
// strength of a filename this side composed itself.
|
|
1085
|
+
if (receipt.backend !== dispatch.backend || receipt.nonce !== nonce) {
|
|
1086
|
+
return refusal('return', `the exec receipt names {backend "${receipt.backend}", nonce "${receipt.nonce}"} but answers the dispatch {backend "${dispatch.backend}", nonce "${nonce}"} — a receipt is bound to its dispatch by identity, not by the filename it was found under; nothing was written`);
|
|
1087
|
+
}
|
|
1088
|
+
// D2 — the wrapper computed this digest INDEPENDENTLY, from the dispatch file it was actually
|
|
1089
|
+
// handed. Without it the correlation would compare the dispatch record against values derived from
|
|
1090
|
+
// itself, and a run that executed a DIFFERENT contract would correlate cleanly.
|
|
1091
|
+
if (receipt.contractDigest !== dispatch.contractDigest) {
|
|
1092
|
+
return refusal('return', `the exec receipt's contractDigest ${receipt.contractDigest.slice(0, 12)}… does not equal the dispatch's ${dispatch.contractDigest.slice(0, 12)}… — the run executed a DIFFERENT contract than the one this thread opened; nothing was written`);
|
|
1093
|
+
}
|
|
1094
|
+
// D8, the two halves of the deadline. The cap the run ACTUALLY applied is a pre-spend fact, so it
|
|
1095
|
+
// is checked on either lane; the timestamp is the artifact's own, and the message names which
|
|
1096
|
+
// state it came from so a `--no-receipt` refusal cannot be read as a terminal one.
|
|
1097
|
+
if (receipt.capS + receipt.killGraceS > dispatch.deadlineS) {
|
|
1098
|
+
return refusal('return', `the exec receipt applied capS ${receipt.capS} plus killGraceS ${receipt.killGraceS} (${receipt.capS + receipt.killGraceS}) against the dispatch's recorded deadlineS ${dispatch.deadlineS} — the run could outlive the deadline this thread was opened under; nothing was written`);
|
|
1099
|
+
}
|
|
1100
|
+
// The window is CLOSED at both ends, inclusive. Only the upper bound existed at first, so a receipt
|
|
1101
|
+
// minted BEFORE this dispatch was absorbed as its answer — and artifact basenames are a function of
|
|
1102
|
+
// {backend, nonce} alone, so a second ledger in the same directory could absorb the first's
|
|
1103
|
+
// terminal artifact. A run cannot answer a dispatch that had not happened yet.
|
|
1104
|
+
const openedAt = Date.parse(dispatch.timestamp);
|
|
1105
|
+
const deadlineAt = openedAt + dispatch.deadlineS * 1000;
|
|
1106
|
+
const stampedAt = Date.parse(receipt.timestamp);
|
|
1107
|
+
if (stampedAt < openedAt) {
|
|
1108
|
+
return refusal('return', `the ${receipt.state} exec receipt is stamped ${receipt.timestamp}, BEFORE this dispatch was opened (${dispatch.timestamp}) — a run cannot answer a dispatch that did not exist yet, and artifact names are a function of {backend, nonce} alone, so an older artifact for the same pair is another thread's; nothing was written`);
|
|
1109
|
+
}
|
|
1110
|
+
if (stampedAt > deadlineAt) {
|
|
1111
|
+
return refusal('return', `the ${receipt.state} exec receipt is stamped ${receipt.timestamp}, past this dispatch's absolute deadline (${dispatch.timestamp} + ${dispatch.deadlineS}s = ${new Date(deadlineAt).toISOString()}) — a LATE return; the deadline is measured from the dispatch record, not from whenever the wrapper happened to start; nothing was written`);
|
|
1112
|
+
}
|
|
1113
|
+
const report = readReportArtifact(ledger.dir, dispatch.backend, nonce);
|
|
1114
|
+
if (!report.ok) return refusal('return', report.reason);
|
|
1115
|
+
// D1's publication order, verified rather than trusted: the report is written atomically FIRST and
|
|
1116
|
+
// the terminal receipt replaces the reservation LAST, so a TERMINAL artifact always has a complete
|
|
1117
|
+
// report behind it. On the --no-receipt lane the semantics are report-IF-PRESENT: an absent report
|
|
1118
|
+
// records reportLength 0 and the metric is then ineligible by the existing name `empty-report`, so
|
|
1119
|
+
// a failed REPORT write and a failed TERMINAL write stay two separately named lanes.
|
|
1120
|
+
if (!noReceipt) {
|
|
1121
|
+
if (report.bytes === null) {
|
|
1122
|
+
return refusal('return', `the terminal exec receipt declares a report of ${receipt.reportLength} byte(s) but no report artifact is at ${report.path} — the report is published BEFORE the receipt replaces the reservation, so a terminal receipt with no report behind it is a state no completed run mints; nothing was written`);
|
|
1123
|
+
}
|
|
1124
|
+
const digest = sha256(report.bytes);
|
|
1125
|
+
if (digest !== receipt.reportDigest || report.bytes.length !== receipt.reportLength) {
|
|
1126
|
+
return refusal('return', `the report at ${report.path} (${report.bytes.length} bytes, ${digest.slice(0, 12)}…) contradicts the terminal receipt (${receipt.reportLength} bytes, ${receipt.reportDigest.slice(0, 12)}…) — the artifact changed after the run published it; nothing was written`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const reportBytes = report.bytes ?? EMPTY_REPORT;
|
|
1130
|
+
const outcome = resolveReturnOutcome(values, receipt, noReceipt);
|
|
1131
|
+
const exitStatus = noReceipt
|
|
1132
|
+
? asInteger('--exit-status', need(values, '--exit-status'))
|
|
1133
|
+
: receipt.exitStatus;
|
|
1134
|
+
const hidden = hiddenFromPlainDiff(top);
|
|
1135
|
+
if (!hidden.ok) return refusal('return', hidden.reason);
|
|
1136
|
+
// The enumeration and the payload are two walks of one tree, so the pair is BRACKETED: the
|
|
1137
|
+
// fingerprint is taken before the walks and the payload's own digest is compared against it after.
|
|
1138
|
+
// A tree that moved between them would hand the numerator one change set and the denominator
|
|
1139
|
+
// another, and the record would bind a postTreeDigest neither of them describes.
|
|
1140
|
+
const openingDigest = uncommittedStateFingerprint(cwd);
|
|
1141
|
+
const enumerated = enumerateReturnedObjects(cwd);
|
|
1142
|
+
if (!enumerated.ok) return refusal('return', enumerated.reason);
|
|
1143
|
+
const opaque = contentBlindRefusal(enumerated.entries);
|
|
1144
|
+
if (opaque !== null) return refusal('return', `${opaque}; nothing was written`);
|
|
1145
|
+
const lostTarget = symlinkTargetRefusal(top, enumerated.entries);
|
|
1146
|
+
if (lostTarget !== null) return refusal('return', `${lostTarget}; nothing was written`);
|
|
1147
|
+
const produced = computeReturnedDiff(cwd);
|
|
1148
|
+
if (!produced.ok) return refusal('return', produced.reason);
|
|
1149
|
+
// The payload IS the diff and its digest IS the uncommitted-state fingerprint — one computation,
|
|
1150
|
+
// so diffDigest and postTreeDigest are equal by construction rather than by coincidence.
|
|
1151
|
+
const postTreeDigest = sha256(produced.diff);
|
|
1152
|
+
const drift = treeDriftRefusal(openingDigest, postTreeDigest);
|
|
1153
|
+
if (drift !== null) return refusal('return', drift);
|
|
1154
|
+
// The fail-closed arm of a producer contradiction (one objectId claimed at two sizes). The
|
|
1155
|
+
// enumeration is this kit's own and emits one entry per object, so nothing here can reach it —
|
|
1156
|
+
// the vocabulary stays the authority on what a component is, and its refusal is surfaced whole.
|
|
1157
|
+
const numerator = computeNumerator(enumerated.entries);
|
|
1158
|
+
if (!numerator.ok) return refusal('return', numerator.reason);
|
|
1159
|
+
const { bundleDigest, bundleLength } = assembleIntegrationBundle(produced.diff, reportBytes);
|
|
1160
|
+
// The LOCALLY provable name wins outright, exactly as the record validator pins it: `dirty-baseline`
|
|
1161
|
+
// is the store-verified override and may only apply where the return's OWN fields leave the metric
|
|
1162
|
+
// eligible, so it can never stand in for a reason a reader could have checked.
|
|
1163
|
+
const local = evaluateMetricEligibility({
|
|
1164
|
+
baselineClean: true,
|
|
1165
|
+
numeratorBytes: numerator.numeratorBytes,
|
|
1166
|
+
diffLength: produced.diff.length,
|
|
1167
|
+
reportLength: reportBytes.length,
|
|
1168
|
+
bundleLength,
|
|
1169
|
+
});
|
|
1170
|
+
const ineligibleReason = local.ineligibleReason ?? (dispatch.baselineClean ? null : 'dirty-baseline');
|
|
1171
|
+
const record = {
|
|
1172
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
1173
|
+
kind: 'return',
|
|
1174
|
+
role: 'execute',
|
|
1175
|
+
backend: dispatch.backend,
|
|
1176
|
+
nonce,
|
|
1177
|
+
contractDigest: dispatch.contractDigest,
|
|
1178
|
+
preTreeDigest: dispatch.preTreeDigest,
|
|
1179
|
+
postTreeDigest,
|
|
1180
|
+
diffDigest: postTreeDigest,
|
|
1181
|
+
diffLength: produced.diff.length,
|
|
1182
|
+
reportDigest: sha256(reportBytes),
|
|
1183
|
+
reportLength: reportBytes.length,
|
|
1184
|
+
bundleDigest,
|
|
1185
|
+
bundleLength,
|
|
1186
|
+
metric: {
|
|
1187
|
+
numeratorBytes: numerator.numeratorBytes,
|
|
1188
|
+
denominatorBytes: bundleLength,
|
|
1189
|
+
components: numerator.components,
|
|
1190
|
+
provenance: RETURN_PROVENANCE_HERE,
|
|
1191
|
+
eligible: ineligibleReason === null,
|
|
1192
|
+
ineligibleReason,
|
|
1193
|
+
},
|
|
1194
|
+
outcome,
|
|
1195
|
+
exitStatus,
|
|
1196
|
+
sessionId: noReceipt ? null : receipt.sessionId,
|
|
1197
|
+
wrapperVersion: receipt.wrapperVersion,
|
|
1198
|
+
posture: { model: receipt.posture.model, effort: receipt.posture.effort, tier: receipt.posture.tier },
|
|
1199
|
+
timestamp: now(),
|
|
1200
|
+
};
|
|
1201
|
+
// The placement check is REPEATED here, immediately before the write. A check-then-write pair is
|
|
1202
|
+
// not atomic, so this NARROWS the window in which the resolved store could be replaced under us
|
|
1203
|
+
// rather than closing it — which is the honest bound of this module's stated posture: it defends
|
|
1204
|
+
// against a buggy or interrupted producer, never against a racing adversary.
|
|
1205
|
+
const stillPlaced = canonicalStoreRefusal(cwd, env);
|
|
1206
|
+
if (stillPlaced !== null) return refusal('return', `${stillPlaced}; nothing was written`);
|
|
1207
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
1208
|
+
const objects = new Set(record.metric.components.map((c) => c.objectId)).size;
|
|
1209
|
+
return {
|
|
1210
|
+
code: 0,
|
|
1211
|
+
stdout: `dispatch return: thread "${nonce}" answered — outcome ${outcome}${noReceipt ? ' (absorbed from the RESERVATION, --no-receipt)' : ''} · exit ${exitStatus} · session ${record.sessionId ?? 'none'} · ${objects} object(s) · ${formatRatio(record.metric)} → ${writtenPath}`,
|
|
1212
|
+
stderr: '',
|
|
1213
|
+
};
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// D4 — the recorded outcome is the wrapper's, or an orchestrator judgment from the closed override
|
|
1217
|
+
// form. The `--no-receipt` lane has no wrapper outcome to keep, so it REQUIRES one and admits only
|
|
1218
|
+
// the outcomes a null sessionId is legal under (dispatch-record.mjs) — which is what makes
|
|
1219
|
+
// `partial-edit` and `stale-return` inexpressible there by name.
|
|
1220
|
+
const resolveReturnOutcome = (values, receipt, noReceipt) => {
|
|
1221
|
+
const requested = values['--outcome'];
|
|
1222
|
+
if (requested !== undefined && !RETURN_OUTCOMES.includes(requested)) {
|
|
1223
|
+
throw usageFail(`--outcome must be one of ${RETURN_OUTCOMES.join(' | ')} (got "${requested}")`);
|
|
1224
|
+
}
|
|
1225
|
+
if (noReceipt) {
|
|
1226
|
+
if (requested === undefined) {
|
|
1227
|
+
throw usageFail(`--outcome is required with --no-receipt — the reservation proves nothing about how the run ended, so the outcome is an orchestrator judgment and is stated, never inferred (one of ${SESSION_ID_NULLABLE_OUTCOMES.join(' | ')})`);
|
|
1228
|
+
}
|
|
1229
|
+
if (!SESSION_ID_NULLABLE_OUTCOMES.includes(requested)) {
|
|
1230
|
+
throw usageFail(`--outcome "${requested}" is outside the --no-receipt set ${SESSION_ID_NULLABLE_OUTCOMES.join(' | ')} — this lane records no session id, and an outcome that requires one is not expressible from a reservation`);
|
|
1231
|
+
}
|
|
1232
|
+
return requested;
|
|
1233
|
+
}
|
|
1234
|
+
if (requested === undefined) return receipt.outcome;
|
|
1235
|
+
const allowed = allowedRecordedOutcomes(receipt.outcome);
|
|
1236
|
+
if (!allowed.includes(requested)) {
|
|
1237
|
+
throw usageFail(`--outcome "${requested}" may not be recorded over a receipt that says "${receipt.outcome}" — a wrapper outcome either stays itself or moves to an orchestrator judgment (${ORCHESTRATOR_OUTCOMES.join(' | ')}), and "success" is recordable only from a receipt that already says success`);
|
|
1238
|
+
}
|
|
1239
|
+
return requested;
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
// ── fold: the integration re-confirmation ─────────────────────────────────────────────────────────
|
|
1243
|
+
|
|
1244
|
+
const runFold = ({ baseCwd, env, argv, now }) => {
|
|
1245
|
+
const { values, operands, cwd } = scan(argv, FOLD_FLAG_FIELDS, baseCwd);
|
|
1246
|
+
refuseOperands('fold', operands);
|
|
1247
|
+
const nonce = need(values, '--nonce');
|
|
1248
|
+
const verdict = need(values, '--verdict');
|
|
1249
|
+
const top = resolveRepoRoot(cwd);
|
|
1250
|
+
if (top === null) {
|
|
1251
|
+
return refusal('fold', 'not inside a git work tree — a fold re-confirms the tree it is folding (fail closed); nothing was written');
|
|
1252
|
+
}
|
|
1253
|
+
const placed = canonicalStoreRefusal(cwd, env);
|
|
1254
|
+
if (placed !== null) return refusal('fold', `${placed}; nothing was written`);
|
|
1255
|
+
const ledger = readLegalLedger(cwd, env);
|
|
1256
|
+
if (!ledger.ok) return refusal('fold', ledger.reason);
|
|
1257
|
+
const state = delegationThreadState(ledger.records, nonce);
|
|
1258
|
+
if (state.dispatch === null) {
|
|
1259
|
+
return refusal('fold', `no dispatch for nonce "${nonce}" is in the store — a fold closes a thread that was opened; nothing was written`);
|
|
1260
|
+
}
|
|
1261
|
+
if (state.return === null) {
|
|
1262
|
+
return refusal('fold', `nonce "${nonce}" carries no return to fold — a fold folds a RETURN, and the thread's last record is a ${state.last.kind}; nothing was written`);
|
|
1263
|
+
}
|
|
1264
|
+
// THE SAME hidden-path guard the absorb door runs, and for a sharper reason: `treeDigestAtFold` is
|
|
1265
|
+
// computed from the very payload that is blind to these paths, so a change made behind an index bit
|
|
1266
|
+
// between the return and the fold leaves the digest EQUAL and the fold would accept bytes nobody
|
|
1267
|
+
// returned. The digest cannot catch what the payload cannot see; this guard is what does.
|
|
1268
|
+
const hidden = hiddenFromPlainDiff(top);
|
|
1269
|
+
if (!hidden.ok) return refusal('fold', `${hidden.reason}; nothing was written`);
|
|
1270
|
+
// …and the same subtraction: an object the payload holds by name alone can be mutated between the
|
|
1271
|
+
// return and the fold without moving the digest this fold is about to bind. The enumeration runs
|
|
1272
|
+
// here for that one question — the fold computes no metric of its own.
|
|
1273
|
+
const enumerated = enumerateReturnedObjects(cwd);
|
|
1274
|
+
if (!enumerated.ok) return refusal('fold', enumerated.reason);
|
|
1275
|
+
const opaque = contentBlindRefusal(enumerated.entries);
|
|
1276
|
+
if (opaque !== null) return refusal('fold', `${opaque}; nothing was written`);
|
|
1277
|
+
const lostTarget = symlinkTargetRefusal(top, enumerated.entries);
|
|
1278
|
+
if (lostTarget !== null) return refusal('fold', `${lostTarget}; nothing was written`);
|
|
1279
|
+
// WHAT THIS BINDING IS, exactly: EQUALITY OF THE VISIBLE UNFRAMED PAYLOAD between the return and
|
|
1280
|
+
// the fold — never an identity of the tree, and the difference is not academic. The payload is an
|
|
1281
|
+
// unframed concatenation, so two DIFFERENT trees can produce the same bytes: probed live, one file
|
|
1282
|
+
// containing the line `untracked:two.txt` yields the same fingerprint as a tree of two files, and a
|
|
1283
|
+
// symlink target can imitate the marker that opens the next untracked entry. It also carries no
|
|
1284
|
+
// git-relevant mode, so making an untracked file executable moves nothing at all. Those are stated
|
|
1285
|
+
// residual limits of a Plan-1 frozen surface the review lane binds too, queued as its own item —
|
|
1286
|
+
// not defects of this verb, and not something this verb may quietly claim away.
|
|
1287
|
+
//
|
|
1288
|
+
// Within that domain the binding does its job: a tree whose payload moved between the return and
|
|
1289
|
+
// the fold refuses, and the recovery is a fresh dispatch. Staging usually moves the payload — every
|
|
1290
|
+
// change set carrying an untracked path does — which is why the fold precedes `git add -A` (D16).
|
|
1291
|
+
// The one shape it does not move is a tracked-only change passing from the worktree into a CLEAN
|
|
1292
|
+
// index: the payload concatenates the staged and unstaged diffs, so those bytes are identical
|
|
1293
|
+
// either side of `git add` (the fingerprint's stated blindness to the index↔worktree split). The
|
|
1294
|
+
// fold is honest there — identical payload bytes, identical content — so the rule is "the fold
|
|
1295
|
+
// precedes staging", never "staging refuses the fold by construction".
|
|
1296
|
+
const treeDigestAtFold = uncommittedStateFingerprint(cwd);
|
|
1297
|
+
const record = {
|
|
1298
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
1299
|
+
kind: 'fold',
|
|
1300
|
+
nonce,
|
|
1301
|
+
returnDigest: canonicalDelegationDigest(state.return),
|
|
1302
|
+
treeDigestAtFold,
|
|
1303
|
+
verdict,
|
|
1304
|
+
timestamp: now(),
|
|
1305
|
+
};
|
|
1306
|
+
// The placement check is REPEATED here, immediately before the write. A check-then-write pair is
|
|
1307
|
+
// not atomic, so this NARROWS the window in which the resolved store could be replaced under us
|
|
1308
|
+
// rather than closing it — which is the honest bound of this module's stated posture: it defends
|
|
1309
|
+
// against a buggy or interrupted producer, never against a racing adversary.
|
|
1310
|
+
const stillPlaced = canonicalStoreRefusal(cwd, env);
|
|
1311
|
+
if (stillPlaced !== null) return refusal('fold', `${stillPlaced}; nothing was written`);
|
|
1312
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
1313
|
+
return {
|
|
1314
|
+
code: 0,
|
|
1315
|
+
stdout: `dispatch fold: thread "${nonce}" folded and CLOSED — return outcome ${state.return.outcome} · tree ${treeDigestAtFold.slice(0, 12)}… · ${formatRatio(state.return.metric)} → ${writtenPath}`,
|
|
1316
|
+
stderr: '',
|
|
1317
|
+
};
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
// ── degrade: the recorded no-fold closure ─────────────────────────────────────────────────────────
|
|
1321
|
+
|
|
1322
|
+
const runDegrade = ({ baseCwd, env, argv, now }) => {
|
|
1323
|
+
const { values, operands, cwd } = scan(argv, DEGRADE_FLAG_FIELDS, baseCwd);
|
|
1324
|
+
refuseOperands('degrade', operands);
|
|
1325
|
+
const ledger = readLegalLedger(cwd, env);
|
|
1326
|
+
if (!ledger.ok) return refusal('degrade', ledger.reason);
|
|
1327
|
+
const nonce = values['--nonce'] ?? null;
|
|
1328
|
+
const record = {
|
|
1329
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
1330
|
+
kind: 'degrade',
|
|
1331
|
+
waveId: need(values, '--wave'),
|
|
1332
|
+
nonce,
|
|
1333
|
+
stepClass: need(values, '--step-class'),
|
|
1334
|
+
rationale: need(values, '--rationale'),
|
|
1335
|
+
timestamp: now(),
|
|
1336
|
+
};
|
|
1337
|
+
const { writtenPath } = appendDelegationRecord({ cwd, record, env });
|
|
1338
|
+
// The PRE-DISPATCH form's consequence is stated where it is recorded, not discovered later: it
|
|
1339
|
+
// opens no nonce thread, so `aggregate` REFUSES the whole wave until the wave's counting unit is
|
|
1340
|
+
// decided. That is a live cost of writing this record, and it is said on the line that writes it.
|
|
1341
|
+
const consequence = nonce === null
|
|
1342
|
+
? ' — a PRE-DISPATCH refusal: it opens NO nonce thread, so `aggregate` will REFUSE this wave by name until the wave\'s counting unit is decided'
|
|
1343
|
+
: ' and CLOSED the thread';
|
|
1344
|
+
return {
|
|
1345
|
+
code: 0,
|
|
1346
|
+
stdout: `dispatch degrade: recorded in wave "${record.waveId}"${consequence} — class ${record.stepClass}${nonce === null ? '' : ` · nonce ${nonce}`} · "${record.rationale}" → ${writtenPath}`,
|
|
1347
|
+
stderr: '',
|
|
1348
|
+
};
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
// ── aggregate: the L0 report over ONE wave ────────────────────────────────────────────────────────
|
|
1352
|
+
|
|
1353
|
+
const WAVE_BEARING_KINDS = ['pre-registration', 'dispatch', 'observation', 'degrade'];
|
|
1354
|
+
|
|
1355
|
+
// Every wave the store mentions — never only the registered ones, so a wave whose registration is
|
|
1356
|
+
// missing is still SELECTED and then refused by name (an unregistered wave must be loud, not absent).
|
|
1357
|
+
export const wavesInStore = (records) =>
|
|
1358
|
+
[...new Set(records.filter((r) => WAVE_BEARING_KINDS.includes(r.kind)).map((r) => r.waveId))];
|
|
1359
|
+
|
|
1360
|
+
export const selectWave = (records, requested) => {
|
|
1361
|
+
const waves = wavesInStore(records);
|
|
1362
|
+
if (requested !== undefined) {
|
|
1363
|
+
return waves.includes(requested)
|
|
1364
|
+
? { ok: true, waveId: requested }
|
|
1365
|
+
: { ok: false, reason: `no record in the delegation store names the wave "${requested}" — there is nothing to aggregate` };
|
|
1366
|
+
}
|
|
1367
|
+
if (waves.length === 0) return { ok: false, reason: 'the delegation store carries no wave — register one first (dispatch.mjs register --wave <id> …)' };
|
|
1368
|
+
if (waves.length > 1) {
|
|
1369
|
+
return { ok: false, reason: `several waves are present (${waves.join(', ')}) and no --wave was given — the selection is AMBIGUOUS, and acceptance is never computed over a guessed scope` };
|
|
1370
|
+
}
|
|
1371
|
+
return { ok: true, waveId: waves[0] };
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
// The closure a thread contributing NO leverage is reported under. D7 grants a metric contribution
|
|
1375
|
+
// to a folded SUCCESS only: a folded acceptance-failure is the orchestrator paying for the same work
|
|
1376
|
+
// a second time (the §7 fold-fix), so its bytes are not leverage — but the thread happened, so it
|
|
1377
|
+
// takes the zero row by its own name rather than vanishing.
|
|
1378
|
+
const zeroLabel = (last, ret) => (last.kind === 'fold'
|
|
1379
|
+
? `folded ${ret.outcome} — the fold-fix is not leverage`
|
|
1380
|
+
: last.kind === 'degrade' ? 'degrade-closed' : `failure-terminal (${last.outcome})`);
|
|
1381
|
+
|
|
1382
|
+
// One nonce thread → its row in the D7 inclusion table. A thread reaches here only after the OPEN
|
|
1383
|
+
// check, so `state.last` is its closure.
|
|
1384
|
+
const classifyThread = (dispatch, state) => {
|
|
1385
|
+
const row = { nonce: dispatch.nonce, stepClass: dispatch.stepClass, retryIndex: dispatch.retryIndex };
|
|
1386
|
+
const ret = state.return;
|
|
1387
|
+
const foldedSuccess = state.last.kind === 'fold' && ret.outcome === 'success';
|
|
1388
|
+
if (!foldedSuccess) {
|
|
1389
|
+
// Failure-terminal, degrade-closed, or a folded non-success: IN n at L = 0 — a delegation that
|
|
1390
|
+
// cost a dispatch and yielded nothing usable is a real zero, never an absence. The bytes it DID
|
|
1391
|
+
// consume stay in the byte-weighted secondary's denominator.
|
|
1392
|
+
const denominatorBytes = ret === null ? 0 : ret.metric.denominatorBytes;
|
|
1393
|
+
return { ...row, inN: true, L: 0, numeratorBytes: 0, denominatorBytes, firstPass: false, disposition: zeroLabel(state.last, ret) };
|
|
1394
|
+
}
|
|
1395
|
+
const metric = ret.metric;
|
|
1396
|
+
const accepted = metric.eligible && metric.provenance === ACCEPTANCE_PROVENANCE;
|
|
1397
|
+
// A retryIndex-0 thread reaching FOLDED SUCCESS is the first pass — including one whose metric is
|
|
1398
|
+
// not acceptance-grade: the attempt landed, only its bytes are unprovable.
|
|
1399
|
+
const firstPass = dispatch.retryIndex === 0;
|
|
1400
|
+
const excluded = metric.eligible ? `provenance ${metric.provenance}` : metric.ineligibleReason;
|
|
1401
|
+
return {
|
|
1402
|
+
...row,
|
|
1403
|
+
inN: accepted,
|
|
1404
|
+
L: accepted ? metric.numeratorBytes / metric.denominatorBytes : null,
|
|
1405
|
+
numeratorBytes: accepted ? metric.numeratorBytes : 0,
|
|
1406
|
+
denominatorBytes: accepted ? metric.denominatorBytes : 0,
|
|
1407
|
+
firstPass,
|
|
1408
|
+
disposition: accepted ? 'folded success' : `folded success, EXCLUDED from the mean (${excluded})`,
|
|
1409
|
+
};
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
const summarizeClass = (stepClass, threads, registration) => {
|
|
1413
|
+
const inClass = threads.filter((t) => t.stepClass === stepClass);
|
|
1414
|
+
const counted = inClass.filter((t) => t.inN);
|
|
1415
|
+
// BigInt accumulation: each byte count is a safe integer on its own, but their SUM need not be,
|
|
1416
|
+
// and a rounded total would quietly move the ratio it feeds. The conversion happens once, at the
|
|
1417
|
+
// division that produces the printed value.
|
|
1418
|
+
const sumNumerator = counted.reduce((sum, t) => sum + BigInt(t.numeratorBytes), 0n);
|
|
1419
|
+
const sumDenominator = counted.reduce((sum, t) => sum + BigInt(t.denominatorBytes), 0n);
|
|
1420
|
+
// The first-pass rate is per CHAIN, so its denominator is the retryIndex-0 threads: counting a
|
|
1421
|
+
// retry as its own attempt would report one piece of work twice.
|
|
1422
|
+
const origins = inClass.filter((t) => t.retryIndex === 0);
|
|
1423
|
+
return {
|
|
1424
|
+
stepClass,
|
|
1425
|
+
threads: inClass,
|
|
1426
|
+
n: counted.length,
|
|
1427
|
+
computed: counted.length >= registration.minPerClass,
|
|
1428
|
+
meanL: counted.length === 0 ? null : counted.reduce((sum, t) => sum + t.L, 0) / counted.length,
|
|
1429
|
+
byteWeightedL: sumDenominator === 0n ? null : Number(sumNumerator) / Number(sumDenominator),
|
|
1430
|
+
firstPassNum: origins.filter((t) => t.firstPass).length,
|
|
1431
|
+
firstPassDen: origins.length,
|
|
1432
|
+
};
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
export const aggregateDelegationWave = (records, waveId) => {
|
|
1436
|
+
const registrationAt = records.findIndex((r) => r.kind === 'pre-registration' && r.waveId === waveId);
|
|
1437
|
+
if (registrationAt === -1) {
|
|
1438
|
+
return { ok: false, reason: `wave "${waveId}" carries NO pre-registration record — acceptance is PRE-REGISTERED before the first observation it counts, so a wave without one is never aggregated` };
|
|
1439
|
+
}
|
|
1440
|
+
const firstAt = records.findIndex((r) => WAVE_BEARING_KINDS.includes(r.kind) && r.waveId === waveId);
|
|
1441
|
+
if (firstAt !== registrationAt) {
|
|
1442
|
+
return { ok: false, reason: `wave "${waveId}" carries a ${records[firstAt].kind} record BEFORE its pre-registration — the registration precedes every record it will count, or the thresholds were chosen after the fact` };
|
|
1443
|
+
}
|
|
1444
|
+
const registration = records[registrationAt];
|
|
1445
|
+
// The read-side half of the pairing-key rule: the store can be hand-written, so the registration
|
|
1446
|
+
// is re-checked here rather than trusted because `register` screened the flag.
|
|
1447
|
+
if (!IMPLEMENTED_PAIRING_KEYS.includes(registration.pairingKey)) {
|
|
1448
|
+
return { ok: false, reason: `wave "${waveId}" is registered under pairing key "${registration.pairingKey}", which this aggregator does not implement (${IMPLEMENTED_PAIRING_KEYS.join(' | ')}) — computing under a different key than the wave recorded would answer a question nobody registered` };
|
|
1449
|
+
}
|
|
1450
|
+
// A pre-dispatch degrade (nonce null) is a RECORDED refusal to delegate that opens no nonce
|
|
1451
|
+
// thread. Counting it would silently widen `n` from D7's terminal THREADS to attempts; printing
|
|
1452
|
+
// it uncounted would let repeated pre-dispatch failures cost the numbers nothing. Neither is this
|
|
1453
|
+
// plan's call to make, so the computation refuses and says which record stopped it.
|
|
1454
|
+
const preDispatch = records.find((r) => r.kind === 'degrade' && r.waveId === waveId && r.nonce === null);
|
|
1455
|
+
if (preDispatch !== undefined) {
|
|
1456
|
+
return { ok: false, reason: `wave "${waveId}" carries a PRE-DISPATCH degrade (class ${preDispatch.stepClass}, "${preDispatch.rationale}") — it opens no nonce thread, so it is neither a terminal thread D7 can count nor a record this computation may ignore; the acceptance computation REFUSES until the wave's counting unit is decided` };
|
|
1457
|
+
}
|
|
1458
|
+
const threads = [];
|
|
1459
|
+
for (const dispatch of records.filter((r) => r.kind === 'dispatch' && r.waveId === waveId)) {
|
|
1460
|
+
const state = delegationThreadState(records, dispatch.nonce);
|
|
1461
|
+
if (state.open) {
|
|
1462
|
+
return { ok: false, reason: `thread "${dispatch.nonce}" (class ${dispatch.stepClass}) is OPEN — the acceptance computation REFUSES over an incomplete wave; close it with a fold or a recorded degrade first` };
|
|
1463
|
+
}
|
|
1464
|
+
threads.push(classifyThread(dispatch, state));
|
|
1465
|
+
}
|
|
1466
|
+
return {
|
|
1467
|
+
ok: true,
|
|
1468
|
+
report: {
|
|
1469
|
+
waveId,
|
|
1470
|
+
registration,
|
|
1471
|
+
classes: registration.stepClasses.map((c) => summarizeClass(c, threads, registration)),
|
|
1472
|
+
observations: records.filter((r) => r.kind === 'observation' && r.waveId === waveId),
|
|
1473
|
+
},
|
|
1474
|
+
};
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────────────────────
|
|
1478
|
+
|
|
1479
|
+
// A solo-construction row is the recorded BASELINE; a self-reported row is observational only. Both
|
|
1480
|
+
// stay out of the acceptance number (which aggregates the wrapper-git domain), and saying so on the
|
|
1481
|
+
// row is what keeps a reader from adding them into it.
|
|
1482
|
+
const observationLine = (record) => {
|
|
1483
|
+
const role = record.metric.provenance === 'solo-construction' ? 'baseline' : 'observational only, EXCLUDED from acceptance';
|
|
1484
|
+
return ` ${record.metric.provenance} · class ${record.stepClass} · plan ${record.planId} phase ${record.phase} · ${formatRatio(record.metric)} · ${role}\n scope: ${record.scope}`;
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
const classLines = (summary, registration) => {
|
|
1488
|
+
const lines = [` class "${summary.stepClass}" — ${summary.threads.length} delegated thread(s):`];
|
|
1489
|
+
for (const t of summary.threads) lines.push(` ${t.nonce} · retry ${t.retryIndex} · ${t.disposition}${t.inN ? ` · L = ${ratio(t.L)}` : ''}`);
|
|
1490
|
+
const firstPass = summary.firstPassDen === 0 ? 'n/a' : ratio(summary.firstPassNum / summary.firstPassDen);
|
|
1491
|
+
if (!summary.computed) {
|
|
1492
|
+
lines.push(` acceptance, class "${summary.stepClass}": NOT COMPUTED — n = ${summary.n} is below the registered minimum ${registration.minPerClass} (insufficient)`);
|
|
1493
|
+
return lines;
|
|
1494
|
+
}
|
|
1495
|
+
const meanMet = summary.meanL >= registration.meanLThreshold ? 'MET' : 'NOT MET';
|
|
1496
|
+
// The rate comparison is EXACT: cross-multiplying in Number rounds once a product leaves the safe
|
|
1497
|
+
// range, and a rounded product can report MET for a rate that is not met. BigInt costs nothing
|
|
1498
|
+
// here and never touches the record schema (the stored values stay JSON numbers).
|
|
1499
|
+
const firstPassMet = BigInt(summary.firstPassNum) * BigInt(registration.firstPassDen)
|
|
1500
|
+
>= BigInt(registration.firstPassNum) * BigInt(summary.firstPassDen) ? 'MET' : 'NOT MET';
|
|
1501
|
+
lines.push(
|
|
1502
|
+
` acceptance, class "${summary.stepClass}": COMPUTED — PILOT evidence (n = ${summary.n})`,
|
|
1503
|
+
` mean L = ${ratio(summary.meanL)} (threshold ${registration.meanLThreshold}) — ${meanMet}`,
|
|
1504
|
+
` byte-weighted L = ${summary.byteWeightedL === null ? 'n/a' : ratio(summary.byteWeightedL)} (SECONDARY)`,
|
|
1505
|
+
` first pass = ${summary.firstPassNum}/${summary.firstPassDen} = ${firstPass} (threshold ${registration.firstPassNum}/${registration.firstPassDen}) — ${firstPassMet}`,
|
|
1506
|
+
);
|
|
1507
|
+
return lines;
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
export const renderAggregate = (report) => {
|
|
1511
|
+
const r = report.registration;
|
|
1512
|
+
const lines = [
|
|
1513
|
+
`delegation aggregate — wave "${report.waveId}"`,
|
|
1514
|
+
` registered ${r.timestamp} — classes ${r.stepClasses.join(' | ')} · pairing key ${r.pairingKey} · minimum ${r.minPerClass} per class · mean L >= ${r.meanLThreshold} · first pass >= ${r.firstPassNum}/${r.firstPassDen}`,
|
|
1515
|
+
` observations — recorded context, never part of the acceptance number:`,
|
|
1516
|
+
...(report.observations.length === 0 ? [' (none)'] : report.observations.map(observationLine)),
|
|
1517
|
+
];
|
|
1518
|
+
for (const summary of report.classes) lines.push(...classLines(summary, r));
|
|
1519
|
+
return lines.join('\n');
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
const runAggregate = ({ baseCwd, env, argv }) => {
|
|
1523
|
+
const { values, operands, cwd } = scan(argv, { '--wave': 'waveId' }, baseCwd);
|
|
1524
|
+
refuseOperands('aggregate', operands);
|
|
1525
|
+
// Legality is re-established BEFORE anything is counted, and by the store's own authority — the
|
|
1526
|
+
// ONE door every read-dependent verb shares. Never a "legal prefix": a ledger that stops making
|
|
1527
|
+
// sense mid-file is unexplained, not shorter.
|
|
1528
|
+
const ledger = readLegalLedger(cwd, env);
|
|
1529
|
+
if (!ledger.ok) return refusal('aggregate', ledger.reason);
|
|
1530
|
+
const selected = selectWave(ledger.records, values['--wave']);
|
|
1531
|
+
if (!selected.ok) return refusal('aggregate', selected.reason);
|
|
1532
|
+
const aggregated = aggregateDelegationWave(ledger.records, selected.waveId);
|
|
1533
|
+
if (!aggregated.ok) return refusal('aggregate', aggregated.reason);
|
|
1534
|
+
return { code: 0, stdout: renderAggregate(aggregated.report), stderr: '' };
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
1538
|
+
|
|
1539
|
+
const HELP = `dispatch — the delegation engine (agent-workflow family): the sub-task contract check, the
|
|
1540
|
+
delegation ledger's hand-recorded records, the four writer verbs that put a delegated thread on the
|
|
1541
|
+
record, the arrival waiter, and the L0 acceptance report.
|
|
1542
|
+
|
|
1543
|
+
Usage:
|
|
1544
|
+
node dispatch.mjs check <dispatch-file> [--cwd <dir>]
|
|
1545
|
+
node dispatch.mjs register --wave <id> --step-classes <c[,c...]>
|
|
1546
|
+
--pairing-key ${IMPLEMENTED_PAIRING_KEYS.join('|')}
|
|
1547
|
+
--min-per-class <n> --mean-l-threshold <x>
|
|
1548
|
+
--first-pass-num <n> --first-pass-den <n> [--cwd <dir>]
|
|
1549
|
+
node dispatch.mjs observe --wave <id> --step-class <c> --scope <path> [--scope <path>...]
|
|
1550
|
+
--plan <id> --phase <n> --provenance ${OBSERVATION_PROVENANCE.join('|')}
|
|
1551
|
+
[--denominator-bytes <n>] [--cwd <dir>]
|
|
1552
|
+
node dispatch.mjs open --contract <dispatch-file> --wave <id> --backend <name>
|
|
1553
|
+
--rationale <text> --wrapper-cap-s <n> --kill-grace-s <n>
|
|
1554
|
+
[--retry-of <nonce>] [--cwd <dir>]
|
|
1555
|
+
node dispatch.mjs await --nonce <n> [--timeout <s>] [--cwd <dir>]
|
|
1556
|
+
node dispatch.mjs return --nonce <n> [--outcome <o>]
|
|
1557
|
+
[--no-receipt --exit-status <n>] [--cwd <dir>]
|
|
1558
|
+
node dispatch.mjs fold --nonce <n> --verdict <text> [--cwd <dir>]
|
|
1559
|
+
node dispatch.mjs degrade --wave <id> --step-class <c> --rationale <text>
|
|
1560
|
+
[--nonce <n>] [--cwd <dir>]
|
|
1561
|
+
node dispatch.mjs aggregate [--wave <id>] [--cwd <dir>]
|
|
1562
|
+
|
|
1563
|
+
check reads the ONE \`\`\`aw-dispatch-contract fenced block in the dispatch file and validates its
|
|
1564
|
+
FORM: ${DISPATCH_CONTRACT}. Exit 0 form-valid; 1 names the FIRST violated field.
|
|
1565
|
+
|
|
1566
|
+
register appends the wave's PRE-REGISTRATION record (immutable per wave: a second one refuses).
|
|
1567
|
+
observe appends ONE observation — provenance ${OBSERVATION_PROVENANCE.join(' or ')} only, since
|
|
1568
|
+
delegated accounting is DERIVED from nonce threads and is never hand-appended. The numerator is the
|
|
1569
|
+
post-image bytes of the objects the scope names; solo-construction takes the same number as its
|
|
1570
|
+
denominator (L = 1 by construction, so --denominator-bytes is refused), self-reported states its own.
|
|
1571
|
+
--scope is REPEATABLE, one repo-relative path per occurrence (no separator is safe inside a POSIX
|
|
1572
|
+
path), anchored at the git TOP-LEVEL so a recorded scope names the same objects from any cwd; the
|
|
1573
|
+
record carries the measured canonical paths as a JSON-encoded array of canonical repo-relative paths,
|
|
1574
|
+
carried in the schema's string field. An object's identity IS its canonical path, so two equal-byte
|
|
1575
|
+
files count twice while one path reached twice counts once. Outside a work tree, for a path whose
|
|
1576
|
+
REAL location leaves the repository (an ancestor symlink included), and for an absent or non-regular
|
|
1577
|
+
path, observe refuses by name. Stated exception to "L = 1 by construction": a solo scope that
|
|
1578
|
+
measures ZERO bytes has no ratio at all and is recorded INELIGIBLE by name (zero-denominator), never
|
|
1579
|
+
as L = 1.
|
|
1580
|
+
|
|
1581
|
+
open appends the DISPATCH record that opens a thread. Every mint-time field — nonce, step class,
|
|
1582
|
+
vehicle, deadline, retry index and cap — is COPIED from the contract header and bound by
|
|
1583
|
+
contractDigest; the tree is fingerprinted (preTreeDigest) and probed for a CLEAN baseline, and a
|
|
1584
|
+
dirty one is recorded, which makes the eventual return metric-INELIGIBLE by name. It REFUSES a
|
|
1585
|
+
deadlineS below --wrapper-cap-s plus --kill-grace-s (both explicit — the kit never reads another
|
|
1586
|
+
package's default), REFUSES a tree that conceals a change (below), and surfaces every retry and wave
|
|
1587
|
+
refusal from the store verbatim.
|
|
1588
|
+
|
|
1589
|
+
THE LEDGER a tree-binding verb uses is the CANONICAL one, exactly: <git common dir>/
|
|
1590
|
+
agent-workflow-delegation.jsonl. A store inside the work tree is measured as part of the change set
|
|
1591
|
+
it is supposed to be measuring; a store in another repository would measure this tree against a
|
|
1592
|
+
foreign thread; a SECOND ledger in this git dir would share artifact names with the first, since
|
|
1593
|
+
those are a function of {backend, nonce} alone. register, observe, await and aggregate keep the
|
|
1594
|
+
unrestricted AW_DELEGATION_STORE override — they bind no tree. open additionally refuses pre-spend
|
|
1595
|
+
when either artifact name for its {backend, nonce} is already taken.
|
|
1596
|
+
|
|
1597
|
+
await watches for ONE dispatch to ANSWER and writes nothing at all. Satisfaction is the arrival of
|
|
1598
|
+
the TERMINAL exec receipt for the dispatch's own {backend, nonce}: a RESERVED artifact means the run
|
|
1599
|
+
holds the nonce and has published nothing about its end, so the wait continues, while an artifact
|
|
1600
|
+
that is not an exec receipt — a review receipt, a delegation ledger line, a finding manifest —
|
|
1601
|
+
REFUSES rather than satisfies, because only an exec receipt answers an exec dispatch. The bound is
|
|
1602
|
+
the ABSOLUTE deadline: it is measured from the DISPATCH record's timestamp, so --timeout defaults to
|
|
1603
|
+
the time REMAINING and a --timeout reaching past the deadline is REFUSED rather than clamped. An
|
|
1604
|
+
already-expired dispatch answers immediately — arrival is read first before EVERY bound, the
|
|
1605
|
+
--timeout admissibility check included, since a receipt on disk is a fact this verb reports and
|
|
1606
|
+
lateness is refused by name at the absorb door. An unanswered wait exits ${AWAIT_UNANSWERED_STATUS}, names whether the
|
|
1607
|
+
DEADLINE or the --timeout ended it, and states that no writer slot was released: a wait that ended
|
|
1608
|
+
without an answer never authorizes the next dispatch.
|
|
1609
|
+
|
|
1610
|
+
A CONCEALING TREE is refused at every door that measures or binds one (open, return, fold), because
|
|
1611
|
+
the recorded baseline, the counted bytes and the folded identity are all claims about a tree that is
|
|
1612
|
+
telling the truth. Two arms: any index entry whose ls-files tag is not "H" refuses on the BIT's
|
|
1613
|
+
presence — deleting a materialized skip-worktree file is invisible to every probe this kit owns, so
|
|
1614
|
+
its effect cannot be compared for — and a per-side, BYTE-keyed comparison of the forced against the
|
|
1615
|
+
plain diff refuses a path an ignore-submodules setting hides. Both name the path; the recovery is
|
|
1616
|
+
git update-index --no-assume-unchanged / --no-skip-worktree, or clearing the diff config.
|
|
1617
|
+
|
|
1618
|
+
return absorbs the wrapper's exec receipt beside the ledger and appends the RETURN record. It reads
|
|
1619
|
+
the TERMINAL receipt (a RESERVED one is a supervision question, never a timeout), checks the
|
|
1620
|
+
receipt's independently computed contractDigest, its {backend, nonce}, its capS+killGraceS against
|
|
1621
|
+
the recorded deadlineS and its timestamp against the ABSOLUTE deadline (the dispatch's timestamp plus
|
|
1622
|
+
deadlineS), re-verifies the report's digest and length, refuses a change set HIDDEN from the plain
|
|
1623
|
+
git diff the metric is computed over (assume-unchanged, skip-worktree, ignore-submodules), and then
|
|
1624
|
+
enumerates the returned objects and frames the canonical integration bundle — bracketed by the tree
|
|
1625
|
+
fingerprint, so a tree that moves mid-computation refuses instead of mixing two change sets.
|
|
1626
|
+
--outcome records an orchestrator judgment under the closed override form: a wrapper outcome either
|
|
1627
|
+
stays itself or moves to ${ORCHESTRATOR_OUTCOMES.join(' | ')}, and "success" is recordable only from
|
|
1628
|
+
a receipt that already says success. --no-receipt is the recovery lane, and it absorbs a RESERVATION
|
|
1629
|
+
ONLY — over a TERMINAL artifact it refuses, because a hand-stated outcome there would discard an exit
|
|
1630
|
+
status, a session id and a report digest the run actually proved. It builds the return from the
|
|
1631
|
+
reservation (wrapperVersion and posture, never hand-typed) plus the tree, REQUIRES --exit-status and
|
|
1632
|
+
an --outcome from ${SESSION_ID_NULLABLE_OUTCOMES.join(' | ')}, and reads the report IF PRESENT — an
|
|
1633
|
+
absent one records length 0 and the metric is then ineligible by name: empty-report where there IS a
|
|
1634
|
+
diff, and no-op-diff where the tree is unchanged too, since the eligibility rule names the diff
|
|
1635
|
+
first. With no artifact at all there is no honest return: close the thread with degrade.
|
|
1636
|
+
|
|
1637
|
+
fold appends the integration re-confirmation and CLOSES the thread. It runs the same hidden-path
|
|
1638
|
+
guard as return — a change made behind an index bit leaves the tree digest EQUAL, so the digest alone
|
|
1639
|
+
cannot catch it — then computes the CURRENT tree digest, which the store binds to the folded return's
|
|
1640
|
+
postTreeDigest. What that binding IS, exactly: equality of the VISIBLE UNFRAMED PAYLOAD, never an
|
|
1641
|
+
identity of the tree. The payload is an unframed concatenation, so two different trees can produce
|
|
1642
|
+
the same bytes (a file's content or a symlink's target can imitate the marker line that opens the
|
|
1643
|
+
next untracked entry), and it carries no git-relevant mode, so making an untracked file executable
|
|
1644
|
+
moves nothing. Those residuals belong to a frozen shared surface and are queued, not claimed away
|
|
1645
|
+
here. Within that domain a tree whose payload moved refuses, and the recovery is a fresh dispatch,
|
|
1646
|
+
never a fold. Staging usually moves the payload (every change set carrying an untracked path does),
|
|
1647
|
+
so the fold precedes git add. The one shape staging does NOT move is a tracked-only change passing
|
|
1648
|
+
into a CLEAN index — the payload concatenates the staged and unstaged diffs, so those bytes are
|
|
1649
|
+
identical either side of git add. The fold stays honest there (same bytes, same content), which is
|
|
1650
|
+
why the rule is "fold before staging", not "staging refuses the fold".
|
|
1651
|
+
|
|
1652
|
+
degrade appends the recorded no-fold closure, threaded (with --nonce) or PRE-DISPATCH (without). The
|
|
1653
|
+
pre-dispatch form opens no nonce thread, so aggregate REFUSES the whole wave by name once one is
|
|
1654
|
+
recorded — stated here because it is a live cost of writing that record.
|
|
1655
|
+
|
|
1656
|
+
aggregate reports ONE wave: the registered thresholds, every observation (context, never acceptance),
|
|
1657
|
+
and per registered step class the delegated threads with the D7 inclusion table applied — a folded
|
|
1658
|
+
success with an eligible wrapper-git metric contributes its L; a folded success whose metric is
|
|
1659
|
+
ineligible or self-reported is EXCLUDED from the mean and from n while still counting in the
|
|
1660
|
+
first-pass rate; a failure-terminal thread, a degrade-closed one, and a folded acceptance-failure
|
|
1661
|
+
(the fold-fix is the orchestrator paying twice, so its bytes are not leverage) are each in n at
|
|
1662
|
+
L = 0 and are never a first pass. The first-pass rate is per retry CHAIN — its denominator is the
|
|
1663
|
+
retryIndex-0 threads — and its threshold comparison is exact. Below the registered
|
|
1664
|
+
minimum acceptance is NOT computed (insufficient); at or above it is computed and labeled PILOT.
|
|
1665
|
+
It REFUSES by name, never guesses: a wave with no pre-registration record; a registration that does
|
|
1666
|
+
not PRECEDE its wave's records; a registration naming a pairing key this aggregator does not
|
|
1667
|
+
implement; an OPEN thread in scope; a PRE-DISPATCH degrade (nonce null), which opens no thread and so
|
|
1668
|
+
is neither countable nor ignorable; several waves present with no --wave; and a malformed or
|
|
1669
|
+
unreadable store.
|
|
1670
|
+
|
|
1671
|
+
Step classes (D9): ${STEP_CLASSES.join(' | ')}.
|
|
1672
|
+
Store: <git common dir>/agent-workflow-delegation.jsonl (AW_DELEGATION_STORE overrides, absolute
|
|
1673
|
+
only) — separate from the review receipts and the flow store, appended under its own lock.
|
|
1674
|
+
|
|
1675
|
+
Exec artifacts (open/await/return): the wrapper's receipt and report are read from the STORE's own
|
|
1676
|
+
directory, named agent-workflow-exec-receipt-<backendLength>-<backend>-<nonce>.json and
|
|
1677
|
+
agent-workflow-exec-report-<backendLength>-<backend>-<nonce>.txt. Honest v1 limits: gate output is
|
|
1678
|
+
never accounted (the wrapper's exit trap removes its trace, so no gate-output component is emitted);
|
|
1679
|
+
a change set carrying a BINARY, non-regular or SUBMODULE object is REFUSED, because the shared
|
|
1680
|
+
payload carries no content for those — a binary by name only, a submodule nothing beyond its first
|
|
1681
|
+
dirty transition — so their bytes can move under a digest that cannot follow them, and the lane is
|
|
1682
|
+
fail-closed for them until that frozen payload can carry it. STATED RESIDUALS on what it does accept:
|
|
1683
|
+
the payload is UNFRAMED, so a file's content or a symlink's target can imitate the marker line that
|
|
1684
|
+
opens the next untracked entry and two different trees can share one fingerprint, and no
|
|
1685
|
+
git-relevant MODE is carried, so making an untracked file executable moves nothing — both are queued
|
|
1686
|
+
against the payload, not claimed away here. A receipt is forgeable exactly like every record here;
|
|
1687
|
+
and D10 stands as a BAR, not a mechanism — at most one in-tree exec dispatch at a time, and nothing
|
|
1688
|
+
refuses a second.
|
|
1689
|
+
|
|
1690
|
+
Never commits, never runs a subscription CLI, spawns nothing but git READS. Exit codes: 0 success;
|
|
1691
|
+
1 a refusal (store STOP verbatim, a form violation, an unreadable file, a supervision question); 2
|
|
1692
|
+
usage; ${AWAIT_UNANSWERED_STATUS} an await that ended with no terminal receipt (the absolute deadline or the --timeout
|
|
1693
|
+
bound) — its own status so a caller that BRANCHES on the code can tell it from a refusal; a caller
|
|
1694
|
+
that discards failure wholesale discards this one too.`;
|
|
1695
|
+
|
|
1696
|
+
export const main = (argv, ctx = {}) => {
|
|
1697
|
+
const env = ctx.env ?? process.env;
|
|
1698
|
+
const now = ctx.now ?? (() => new Date().toISOString());
|
|
1699
|
+
try {
|
|
1700
|
+
// Help is the FIRST argument or nothing: past the verb, `--help` is an ordinary operand or an
|
|
1701
|
+
// already-claimed flag value, so `check --help` reads a file by that name rather than turning a
|
|
1702
|
+
// write that was asked for into a silent exit 0.
|
|
1703
|
+
if (HELP_FLAGS.has(argv[0])) return { code: 0, stdout: HELP, stderr: '' };
|
|
1704
|
+
const [verb, ...rest] = argv;
|
|
1705
|
+
const baseCwd = ctx.cwd ?? process.cwd();
|
|
1706
|
+
if (verb === 'check') return runCheck({ argv: rest, baseCwd });
|
|
1707
|
+
if (verb === 'register') return runRegister({ baseCwd, env, argv: rest, now });
|
|
1708
|
+
if (verb === 'observe') return runObserve({ baseCwd, env, argv: rest, now });
|
|
1709
|
+
if (verb === 'open') return runOpen({ baseCwd, env, argv: rest, now });
|
|
1710
|
+
if (verb === 'return') return runReturn({ baseCwd, env, argv: rest, now });
|
|
1711
|
+
if (verb === 'fold') return runFold({ baseCwd, env, argv: rest, now });
|
|
1712
|
+
if (verb === 'degrade') return runDegrade({ baseCwd, env, argv: rest, now });
|
|
1713
|
+
if (verb === 'aggregate') return runAggregate({ baseCwd, env, argv: rest });
|
|
1714
|
+
if (verb === 'await') {
|
|
1715
|
+
throw usageFail('await is the one verb that WAITS, so it answers through mainAwait (the CLI routes it there) — main() returns the answer a verb has already computed, and a promise handed back here would read as a result object with no code at all');
|
|
1716
|
+
}
|
|
1717
|
+
throw usageFail(`unknown verb: ${verb ?? '(none)'} — expected check | register | observe | open | await | return | fold | degrade | aggregate (see --help)`);
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `dispatch: ${err.message}` };
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
// The ASYNC superset: it answers the ONE waiting verb and delegates every other one to main(), so
|
|
1724
|
+
// the two entries can never carry different verb sets. The review lane's mainAwait (review-state.mjs)
|
|
1725
|
+
// is the idiom — a waiting entry beside a synchronous one, rather than making every immediate verb's
|
|
1726
|
+
// answer a promise.
|
|
1727
|
+
export const mainAwait = async (argv, ctx = {}) => {
|
|
1728
|
+
if (argv[0] !== 'await') return main(argv, ctx);
|
|
1729
|
+
try {
|
|
1730
|
+
return await runAwait({
|
|
1731
|
+
baseCwd: ctx.cwd ?? process.cwd(),
|
|
1732
|
+
env: ctx.env ?? process.env,
|
|
1733
|
+
argv: argv.slice(1),
|
|
1734
|
+
now: ctx.now ?? (() => new Date().toISOString()),
|
|
1735
|
+
sleep: ctx.sleep ?? ((ms) => new Promise((done) => { setTimeout(done, ms); })),
|
|
1736
|
+
pollMs: ctx.pollMs ?? EXEC_AWAIT_POLL_MS,
|
|
1737
|
+
});
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `dispatch: ${err.message}` };
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
// runCli(argv, io) → the exit code, after writing main()'s streams. EXPORTED so the process-facing
|
|
1744
|
+
// lane is exercised IN-PROCESS: a spawned child's executed lines never reach the coverage map, so a
|
|
1745
|
+
// CLI whose only entry is a spawn ships an unmeasured tail. runCliAwait is its waiting twin — the
|
|
1746
|
+
// SAME writer over mainAwait's answer, so the two lanes cannot drift in how they emit.
|
|
1747
|
+
const emitResult = (r, io) => {
|
|
1748
|
+
if (r.stdout) (io.stdout ?? process.stdout).write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
1749
|
+
if (r.stderr) (io.stderr ?? process.stderr).write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
1750
|
+
return r.code;
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
export const runCli = (argv, io = {}) => emitResult(main(argv, io.ctx), io);
|
|
1754
|
+
|
|
1755
|
+
export const runCliAwait = async (argv, io = {}) => emitResult(await mainAwait(argv, io.ctx), io);
|
|
1756
|
+
|
|
1757
|
+
// runEntryPoint(argv, setExitCode, io) — the ONE routing rule the process entry uses: the waiting
|
|
1758
|
+
// verb rides the async writer, every other one the synchronous lane it has always had. Exported and
|
|
1759
|
+
// driven IN-PROCESS, because a spawned child's executed lines never reach the coverage map and a
|
|
1760
|
+
// routing rule that only runs inside `if (isEntryPoint(…))` would otherwise ship unmeasured.
|
|
1761
|
+
export const runEntryPoint = (argv, setExitCode, io = {}) => (argv[0] === 'await'
|
|
1762
|
+
? runCliAwait(argv, io).then(setExitCode)
|
|
1763
|
+
: setExitCode(runCli(argv, io)));
|
|
1764
|
+
|
|
1765
|
+
// isEntryPoint(entry, moduleFile, realpath) — "did the process start THIS module?", decided by REAL
|
|
1766
|
+
// PATH rather than by comparing URL strings. The kit is reached through managed symlinks (the
|
|
1767
|
+
// documented Codex skill install links the wrappers onto PATH), and Node reports the entry as the
|
|
1768
|
+
// symlink while a module knows itself by its real path — so a string comparison makes the CLI exit
|
|
1769
|
+
// 0 having silently done nothing. An unresolvable side falls back to its lexical resolve, so a
|
|
1770
|
+
// deleted entry can never make the module claim a process it did not start.
|
|
1771
|
+
export const isEntryPoint = (entry, moduleFile, realpath = realpathSync) => {
|
|
1772
|
+
if (typeof entry !== 'string' || entry === '') return false;
|
|
1773
|
+
const real = (p) => { try { return realpath(p); } catch { return resolve(p); } };
|
|
1774
|
+
return real(entry) === real(moduleFile);
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
// `process.exitCode`, never process.exit: an exact write plus a natural exit, so a piped stdout is
|
|
1778
|
+
// never truncated by the waiting lane's extra tick.
|
|
1779
|
+
if (isEntryPoint(process.argv[1], fileURLToPath(import.meta.url))) runEntryPoint(process.argv.slice(2), (code) => { process.exitCode = code; });
|