@dzhechkov/harness-core 0.4.5 → 0.5.1
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/.dz-manifest.json +489 -105
- package/README.md +116 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +324 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +95 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +169 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +341 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +541 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +218 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +583 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +70 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +103 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +37 -129
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-run-semantics.d.ts +130 -0
- package/dist/loop-run-semantics.d.ts.map +1 -0
- package/dist/loop-run-semantics.js +257 -0
- package/dist/loop-run-semantics.js.map +1 -0
- package/dist/loop-trace.d.ts +31 -2
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +89 -12
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +86 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +101 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts +57 -0
- package/dist/named-lock.d.ts.map +1 -0
- package/dist/named-lock.js +247 -0
- package/dist/named-lock.js.map +1 -0
- package/dist/operations.d.ts +229 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +880 -26
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +106 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +209 -5
- package/dist/parity.js.map +1 -1
- package/dist/qe-bridge.d.ts +291 -0
- package/dist/qe-bridge.d.ts.map +1 -0
- package/dist/qe-bridge.js +538 -0
- package/dist/qe-bridge.js.map +1 -0
- package/dist/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +230 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -0
- package/dist/workflow-run-dispatch.js +363 -0
- package/dist/workflow-run-dispatch.js.map +1 -0
- package/dist/workflow-run.d.ts +498 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1359 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +6 -5
- package/sbom.json +1099 -139
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +328 -0
- package/src/codex-hooks-verify.ts +237 -0
- package/src/codex-hooks.ts +723 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +697 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +162 -2
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +37 -127
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +84 -10
- package/src/managed-hooks.ts +150 -0
- package/src/mutation-gate.ts +24 -2
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +1121 -29
- package/src/parity.ts +295 -6
- package/src/qe-bridge.ts +737 -0
- package/src/recall-usage.ts +184 -1
- package/src/score.ts +50 -9
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1743 -0
package/src/loop-render.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type LoopStep,
|
|
35
35
|
} from './loop-plan.js';
|
|
36
36
|
import { BLOBS, type LoopBlob } from './loop-blobs.generated.js';
|
|
37
|
+
import { computeBudgetTotal, stepContractLines } from './loop-run-semantics.js';
|
|
37
38
|
|
|
38
39
|
export const LOOP_RENDER_GENERATOR = 'loop-render/1';
|
|
39
40
|
|
|
@@ -76,6 +77,12 @@ function sha256(s: string): string {
|
|
|
76
77
|
/** Which blobs a plan pulls in (opt-in subsystems + auto rules + requires closure). */
|
|
77
78
|
export function selectBlobs(plan: LoopPlan): LoopBlob[] {
|
|
78
79
|
const names = new Set<string>();
|
|
80
|
+
// The ENACTMENT-DECISION blob is UNCONDITIONAL (ADR-001 W4): the base runtime aliases
|
|
81
|
+
// errText/errSnap/classifyFailure in every script, so there is no plan that does not need it.
|
|
82
|
+
// gateVerdict/joinRegion are inert function declarations in a plan with no gate/fanout — the
|
|
83
|
+
// alternative (a conditional roster) would make the exec fingerprint's `tools` axis depend on
|
|
84
|
+
// plan shape twice, and would let a semantics-carrying script exist without the semantics.
|
|
85
|
+
names.add('loop-semantics');
|
|
79
86
|
const sub = plan.subsystems ?? {};
|
|
80
87
|
if (plan.checkpointing?.enabled === true || sub.checkpoints === true) names.add('checkpoints');
|
|
81
88
|
// AM-9: training pairs are injected ONLY on an explicit opt-in — never by default (PHI lesson).
|
|
@@ -189,26 +196,22 @@ function stepUserLines(s: LoopStep): string[] {
|
|
|
189
196
|
function stepPromptAssembly(s: LoopStep, plan: LoopPlan): string[] {
|
|
190
197
|
const id = s.stepId;
|
|
191
198
|
const lines: string[] = [];
|
|
192
|
-
const reads = s.artifacts?.reads ?? [];
|
|
193
|
-
const writes = s.artifacts?.writes ?? [];
|
|
194
199
|
const gateCfg = s.kind === 'gate' ? (plan.gates ?? []).find((g) => g.stepId === id) : undefined;
|
|
195
200
|
lines.push(`const P_${ident(id)} = [`);
|
|
196
201
|
lines.push(` USER_PROMPT_${ident(id)},`);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
if (s.kind === 'gate') {
|
|
211
|
-
lines.push(` ${jsString('GATE PROTOCOL (kind: ' + (gateCfg?.kind ?? 'gate') + '): end your reply with exactly one line "GATE: PASS" or "GATE: FAIL" — the loop PARSES this verdict and never synthesizes one.')},`);
|
|
202
|
+
// The contract lines themselves come from ONE function shared with the dz runner
|
|
203
|
+
// (loop-run-semantics.stepContractLines, ADR-001 Confirmation-5) — the render's only remaining
|
|
204
|
+
// job is JS-quoting them. ENACTS artifacts.reads/writes/deliverable, LoopStep.tools and the gate
|
|
205
|
+
// protocol: the plan's declarations are COMMUNICATED to the agent in FIXED wording, so their
|
|
206
|
+
// presence is greppable by a layer-1 test and IDENTICAL under both enactors.
|
|
207
|
+
for (const line of stepContractLines({
|
|
208
|
+
reads: s.artifacts?.reads ?? [],
|
|
209
|
+
writes: s.artifacts?.writes ?? [],
|
|
210
|
+
deliverable: s.deliverable ?? 'return-value',
|
|
211
|
+
tools: s.tools ?? [],
|
|
212
|
+
gate: s.kind === 'gate' ? { kind: gateCfg?.kind ?? 'gate' } : null,
|
|
213
|
+
})) {
|
|
214
|
+
lines.push(` ${jsString(line)},`);
|
|
212
215
|
}
|
|
213
216
|
lines.push(`].join('\\n')`);
|
|
214
217
|
return lines;
|
|
@@ -477,15 +480,10 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
|
|
|
477
480
|
const traceOn = plan.trace?.emit === true;
|
|
478
481
|
const ckptOn = plan.checkpointing?.enabled === true || plan.subsystems?.checkpoints === true;
|
|
479
482
|
// budget: declared per-step budgets PLUS the declared gate-redo allowance (QE round-3 B1 — a
|
|
480
|
-
// plan-declared redo must be affordable; an undeclared one still hits the guard loudly).
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
const redos = typeof g.maxRedos === 'number' && Number.isFinite(g.maxRedos) && g.maxRedos > 0 ? Math.floor(g.maxRedos) : 0;
|
|
485
|
-
if (redos === 0 || typeof g.failRoute !== 'string' || g.failRoute.startsWith('terminal:')) return n;
|
|
486
|
-
return n + redos * ((byIdB.get(g.failRoute)?.budget?.maxAgents ?? 1) + (byIdB.get(g.stepId)?.budget?.maxAgents ?? 1));
|
|
487
|
-
}, 0);
|
|
488
|
-
const budgetTotal = stepBudget + gateRedoBudget;
|
|
483
|
+
// plan-declared redo must be affordable; an undeclared one still hits the guard loudly). The
|
|
484
|
+
// FORMULA lives in loop-run-semantics (ADR-004 Confirmation-2) so the dz runner reserves against
|
|
485
|
+
// the same ceiling this script spends against — one formula, two enactors.
|
|
486
|
+
const budgetTotal = computeBudgetTotal(plan);
|
|
489
487
|
const lines: string[] = [];
|
|
490
488
|
lines.push(G('runtime'));
|
|
491
489
|
lines.push(`const A = typeof args === 'string' ? JSON.parse(args) : (args || {})`);
|
|
@@ -518,82 +516,15 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
|
|
|
518
516
|
lines.push(`function __settleSeqOf(stepId, itemKey, occ) { const v = __settled[__settleKey(stepId, itemKey, occ)]; return typeof v === 'number' ? v : -1 }`);
|
|
519
517
|
lines.push(`let __invocationN = 0`);
|
|
520
518
|
lines.push(`let __seqFallback = 0 // used only when the trace blob is opted out (hooks are no-ops)`);
|
|
521
|
-
lines.push(`//
|
|
522
|
-
lines.push(`//
|
|
523
|
-
lines.push(`//
|
|
524
|
-
lines.push(`//
|
|
525
|
-
lines.push(`//
|
|
526
|
-
lines.push(
|
|
527
|
-
lines.push(`
|
|
528
|
-
lines.push(`
|
|
529
|
-
lines.push(`
|
|
530
|
-
lines.push(` return String(err)`);
|
|
531
|
-
lines.push(` } catch (_e) {`);
|
|
532
|
-
lines.push(` try { return Object.prototype.toString.call(err) } catch (_e2) { return '[unrenderable error]' }`);
|
|
533
|
-
lines.push(` }`);
|
|
534
|
-
lines.push(`}`);
|
|
535
|
-
lines.push(`// failure classification (G4, hardened in QE round-3 B3, round-4: cause-chain + word bounds) —`);
|
|
536
|
-
lines.push(`// the CLOSED enum from loop-plan/1 (timeout | transport | malformed-output | policy-refusal).`);
|
|
537
|
-
lines.push(`// The err.cause CHAIN is traversed (bounded depth 5, cycle-safe, getter-safe): the standard`);
|
|
538
|
-
lines.push(`// Node fetch shape TypeError('fetch failed', {cause:{code:'ECONNRESET'}}) is TRANSPORT.`);
|
|
539
|
-
lines.push(`// THREE TIERS over the whole chain, strongest first:`);
|
|
540
|
-
lines.push(`// 1. error CODE (works on non-Error shapes like {code:'ECONNRESET'}; never message-dependent;`);
|
|
541
|
-
lines.push(`// ETIMEDOUT is a TRANSPORT code — round-2's message regex captured it as 'timeout' first);`);
|
|
542
|
-
lines.push(`// 2. error NAME (SyntaxError = parsing model output failed → malformed-output);`);
|
|
543
|
-
lines.push(`// 3. message patterns, DISJOINT by precedence transport > policy-refusal > malformed-output >`);
|
|
544
|
-
lines.push(`// timeout — every alternative WORD-BOUNDED (round-4 B3: 'rate.?limit' unbounded matched`);
|
|
545
|
-
lines.push(`// 'delibeRATE LIMITation'/'corpoRATE LIMITation' — a substring can never smuggle a class).`);
|
|
546
|
-
lines.push(`// An UNCLASSIFIABLE failure returns null and is NEVER retried (closed enum, fail-closed).`);
|
|
547
|
-
lines.push(`function __causeChain(err) {`);
|
|
548
|
-
lines.push(` const chain = []`);
|
|
549
|
-
lines.push(` let cur = err`);
|
|
550
|
-
lines.push(` for (let d = 0; d < 5; d++) {`);
|
|
551
|
-
lines.push(` if (cur === null || cur === undefined) break`);
|
|
552
|
-
lines.push(` if (chain.indexOf(cur) !== -1) break // cycle-safe`);
|
|
553
|
-
lines.push(` chain.push(cur)`);
|
|
554
|
-
lines.push(` try { cur = typeof cur === 'object' ? cur.cause : undefined } catch (_e) { cur = undefined } // getter-safe`);
|
|
555
|
-
lines.push(` }`);
|
|
556
|
-
lines.push(` return chain.length > 0 ? chain : [err]`);
|
|
557
|
-
lines.push(`}`);
|
|
558
|
-
lines.push(`// __errSnap — ONE snapshot PER FAILURE (round-6 B3; Codex round-5: .message was snapshot once`);
|
|
559
|
-
lines.push(`// per __errText INVOCATION, not once per failure — logging read it, classification read it`);
|
|
560
|
-
lines.push(`// AGAIN, so a one-shot .message getter answered the log and defeated classification: 2 getter`);
|
|
561
|
-
lines.push(`// reads, 1 attempt MEASURED). The catch site builds this snapshot ONCE; the log line and the`);
|
|
562
|
-
lines.push(`// classifier both consume the SNAPSHOT — .code/.name/.message are each read exactly once per`);
|
|
563
|
-
lines.push(`// failure, getter-safe, over the whole cause chain.`);
|
|
564
|
-
lines.push(`function __errSnap(err) {`);
|
|
565
|
-
lines.push(` const chain = __causeChain(err)`);
|
|
566
|
-
lines.push(` const snap = []`);
|
|
567
|
-
lines.push(` for (let ci = 0; ci < chain.length; ci++) {`);
|
|
568
|
-
lines.push(` let code = null`);
|
|
569
|
-
lines.push(` try { const c = chain[ci] !== null && typeof chain[ci] === 'object' ? chain[ci].code : null; code = typeof c === 'string' ? c.toUpperCase() : null } catch (_e) { code = null }`);
|
|
570
|
-
lines.push(` let name = null`);
|
|
571
|
-
lines.push(` try { const n = chain[ci] !== null && typeof chain[ci] === 'object' ? chain[ci].name : null; name = typeof n === 'string' ? n : null } catch (_e) { name = null }`);
|
|
572
|
-
lines.push(` snap.push({ code: code, name: name, text: __errText(chain[ci]) })`);
|
|
573
|
-
lines.push(` }`);
|
|
574
|
-
lines.push(` return snap`);
|
|
575
|
-
lines.push(`}`);
|
|
576
|
-
lines.push(`function __classifyFailure(outcome, snap) {`);
|
|
577
|
-
lines.push(` if (outcome === 'null') return 'transport' // a dead/empty agent is a delivery failure — the "agent died" case is retryable ONLY under retryOn: ['transport']`);
|
|
578
|
-
lines.push(` const links = Array.isArray(snap) ? snap : []`);
|
|
579
|
-
lines.push(` for (let ci = 0; ci < links.length; ci++) {`);
|
|
580
|
-
lines.push(` const code = links[ci].code`);
|
|
581
|
-
lines.push(` if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EPIPE' || code === 'ECONNABORTED' || code === 'EAI_AGAIN') return 'transport'`);
|
|
582
|
-
lines.push(` }`);
|
|
583
|
-
lines.push(` for (let ci = 0; ci < links.length; ci++) {`);
|
|
584
|
-
lines.push(` if (links[ci].name === 'SyntaxError') return 'malformed-output'`);
|
|
585
|
-
lines.push(` }`);
|
|
586
|
-
lines.push(` let msg = ''`);
|
|
587
|
-
lines.push(` for (let ci = 0; ci < links.length; ci++) { msg += (ci > 0 ? '\\n' : '') + links[ci].text }`);
|
|
588
|
-
lines.push(` msg = msg.toLowerCase()`);
|
|
589
|
-
lines.push(` // rate[ -]?limit(ed|ing|s)? is RIGHT-BOUNDED (round-5 B3: the open 'rate.?limit' matched`);
|
|
590
|
-
lines.push(` // 'rate limitation: invalid JSON' as transport — a malformed-output failure smuggled a class)`);
|
|
591
|
-
lines.push(` if (/\\btransport\\b|\\beconnreset\\b|\\beconnrefused\\b|\\benotfound\\b|\\bepipe\\b|\\betimedout\\b|\\bsocket hang up\\b|\\bnetwork error\\b|\\brate[ -]?limit(ed|ing|s)?\\b|\\boverloaded\\b|\\bhttp 5[0-9][0-9]\\b/.test(msg)) return 'transport'`);
|
|
592
|
-
lines.push(` if (/\\bpolicy\\b|\\brefus(e|ed|es|al|ing)\\b|\\bdeclin(e|ed|es|ing)\\b|\\bcontent filter\\b|\\bsafety block\\b/.test(msg)) return 'policy-refusal'`);
|
|
593
|
-
lines.push(` if (/\\bmalformed\\b|\\bunparseable\\b|\\bparse error\\b|\\binvalid json\\b|\\bunexpected token\\b|\\bunexpected end of json\\b|\\bschema mismatch\\b/.test(msg)) return 'malformed-output'`);
|
|
594
|
-
lines.push(` if (/\\btimeout\\b|\\btimed out\\b/.test(msg)) return 'timeout'`);
|
|
595
|
-
lines.push(` return null`);
|
|
596
|
-
lines.push(`}`);
|
|
519
|
+
lines.push(`// The enactment DECISIONS below are ALIASES onto the always-on \`loop-semantics\` blob`);
|
|
520
|
+
lines.push(`// (ADR-001 W4): errText/causeChain/errSnap/classifyFailure/gateVerdict/joinRegion have ONE`);
|
|
521
|
+
lines.push(`// implementation, sliced into this script from harness-core/src/loop-run-semantics.ts and`);
|
|
522
|
+
lines.push(`// IMPORTED (not copied) by the dz runner. The __-prefixed names are kept so every call site`);
|
|
523
|
+
lines.push(`// in this runtime reads exactly as it did when the bodies lived here.`);
|
|
524
|
+
lines.push(`const __errText = errText`);
|
|
525
|
+
lines.push(`const __causeChain = causeChain`);
|
|
526
|
+
lines.push(`const __errSnap = errSnap`);
|
|
527
|
+
lines.push(`const __classifyFailure = classifyFailure`);
|
|
597
528
|
lines.push(`// __settleStep — THE single terminal exit of every step path (QE round-5 B3 class-kill;`);
|
|
598
529
|
lines.push(`// round-6: SUCCESS-PATH PARITY — Codex round-5 showed __settleStep({outcome:'ok'}) returned`);
|
|
599
530
|
lines.push(`// BEFORE flushing, leaving success durability on a naked phase-boundary await whose rejection`);
|
|
@@ -684,17 +615,8 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
|
|
|
684
615
|
lines.push(` if (lastErr !== null) throw lastErr`);
|
|
685
616
|
lines.push(` return null`);
|
|
686
617
|
lines.push(`}`);
|
|
687
|
-
lines.push(`// join helper — explicit policy from the closed set; a dispatched branch is never skippable
|
|
688
|
-
lines.push(
|
|
689
|
-
lines.push(` const policy = o && o.policy ? o.policy : 'all-activated'`);
|
|
690
|
-
lines.push(` const failures = []`);
|
|
691
|
-
lines.push(` for (let i = 0; i < results.length; i++) { if (results[i] === null || results[i] === undefined) failures.push(i) }`);
|
|
692
|
-
lines.push(` if (policy === 'any') { if (failures.length === results.length) { throw new Error('join ' + o.region + ': every branch failed (policy any)') } return { ok: true, values: results, failures: failures } }`);
|
|
693
|
-
lines.push(` const quorum = /^quorum:([1-9][0-9]*)$/.exec(policy)`);
|
|
694
|
-
lines.push(` if (quorum) { const okN = results.length - failures.length; if (okN < Number(quorum[1])) { throw new Error('join ' + o.region + ': quorum ' + quorum[1] + ' not met (' + okN + ' ok)') } return { ok: true, values: results, failures: failures } }`);
|
|
695
|
-
lines.push(` if (failures.length > 0) { throw new Error('join ' + o.region + ': ' + failures.length + ' dispatched branch(es) failed under policy ' + policy + ' — a dispatched branch is never skippable') }`);
|
|
696
|
-
lines.push(` return { ok: true, values: results, failures: [] }`);
|
|
697
|
-
lines.push(`}`);
|
|
618
|
+
lines.push(`// join helper — explicit policy from the closed set; a dispatched branch is never skippable.`);
|
|
619
|
+
lines.push(`// The DECISION lives in the loop-semantics blob above (\`joinRegion\`), called directly here.`);
|
|
698
620
|
if (plan.steps.some((s) => s.kind === 'fanout')) {
|
|
699
621
|
// ── QUIESCENCE (QE round-7 B3, the round-6 reviewer's FOURTH CLASS: structured-concurrency /
|
|
700
622
|
// quiescence ownership). MEASURED by Codex on the previous shape: the region awaited a
|
|
@@ -871,20 +793,8 @@ function renderRuntime(plan: LoopPlan, planDig: string, execFp: string, blobs: L
|
|
|
871
793
|
lines.push(`// must be the ONLY anchored verdict line. Embedded mid-reply "GATE: PASS" text never counts,`);
|
|
872
794
|
lines.push(`// "GATE: PASS" followed by trailing prose is invalid, and "GATE: FAIL … GATE: PASS" is an`);
|
|
873
795
|
lines.push(`// INVALID verdict (never a success) — routed like a failure (redo/fail route), never a pass.`);
|
|
874
|
-
lines.push(
|
|
875
|
-
lines.push(`
|
|
876
|
-
lines.push(` const vLines = r.split('\\n')`);
|
|
877
|
-
lines.push(` const vRe = /^\\s*GATE:\\s*(PASS|FAIL)\\s*$/`);
|
|
878
|
-
lines.push(` let vCount = 0`);
|
|
879
|
-
lines.push(` let vLast = ''`);
|
|
880
|
-
lines.push(` for (let i = 0; i < vLines.length; i++) {`);
|
|
881
|
-
lines.push(` if (vRe.test(vLines[i])) { vCount++ }`);
|
|
882
|
-
lines.push(` if (vLines[i].trim() !== '') { vLast = vLines[i] }`);
|
|
883
|
-
lines.push(` }`);
|
|
884
|
-
lines.push(` const vEnd = vRe.exec(vLast)`);
|
|
885
|
-
lines.push(` if (vCount !== 1 || vEnd === null) return 'invalid'`);
|
|
886
|
-
lines.push(` return vEnd[1] === 'PASS' ? 'pass' : 'fail'`);
|
|
887
|
-
lines.push(`}`);
|
|
796
|
+
lines.push(`// The grammar itself lives in the loop-semantics blob, shared with the dz runner.`);
|
|
797
|
+
lines.push(`const __gateVerdict = gateVerdict`);
|
|
888
798
|
}
|
|
889
799
|
lines.push(GE('runtime'));
|
|
890
800
|
return lines.join('\n');
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop-run-semantics` — the ONE home of loop-designer's ENACTMENT DECISIONS (feature
|
|
3
|
+
* dz-workflow-run, ADR-001 W4).
|
|
4
|
+
*
|
|
5
|
+
* Before this module these semantics existed ONLY as template strings inside
|
|
6
|
+
* `loop-render.ts:renderRuntime` — readable by the generated Claude-host script and by nobody
|
|
7
|
+
* else. A second enactor (`dz workflow run`) would have had to COPY them, and two copies of a
|
|
8
|
+
* gate-verdict grammar is exactly how a runner comes to synthesize a pass the render would have
|
|
9
|
+
* refused. So the decisions move here once and are consumed twice:
|
|
10
|
+
* • the generated script gets them as a BLOB (`scripts/gen-loop-blobs.mjs`, blob `loop-semantics`,
|
|
11
|
+
* always on — the base runtime references errText/classifyFailure in every script);
|
|
12
|
+
* • the dz runner imports them directly.
|
|
13
|
+
* "Imported, not copied" stops being an intention and becomes a fact a test can check.
|
|
14
|
+
*
|
|
15
|
+
* SCOPE, honestly (ADR-001 names it): what moves is DECISION semantics. `__drainAll`,
|
|
16
|
+
* `runStep` and `__settleStep` do NOT move — they are HOST-STRUCTURAL (they wrap the sandbox's
|
|
17
|
+
* `parallel()`/`agent()` and its settle discipline); the runner has its own structured concurrency
|
|
18
|
+
* and its own settle path.
|
|
19
|
+
*
|
|
20
|
+
* BLOB-SOURCE DISCIPLINE (same rule as `loop-trace.ts`): this module has NO RUNTIME IMPORT — its
|
|
21
|
+
* single `import type` is erased at compile time, so the generator can slice declarations out of it
|
|
22
|
+
* with no import to resolve. The generator's INV-12 output ban (fs / clock / randomness / process)
|
|
23
|
+
* holds here by construction: every function below is pure.
|
|
24
|
+
*
|
|
25
|
+
* One consequence of that discipline is visible in the signatures: the six BLOB-EXPORTED functions
|
|
26
|
+
* may not mention an IMPORTED type by bare name (the slicer would see an unresolvable cross-file
|
|
27
|
+
* reference and fail closed), so `classifyFailure` spells its return type as the inline import type
|
|
28
|
+
* `import('./loop-plan.js').FailureClass`. It is the SAME closed enum — one domain, not a restated
|
|
29
|
+
* copy — written in the one form the slicer can carry.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Deliverable, LoopPlan } from './loop-plan.js';
|
|
33
|
+
|
|
34
|
+
/** Blob version stamp read by scripts/gen-loop-blobs.mjs. */
|
|
35
|
+
export const LOOP_RUN_SEMANTICS_BLOB_VERSION = '1.0.0';
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
// BLOB EXPORTS — sliced verbatim into every rendered script AND imported by the runner.
|
|
39
|
+
// Byte-semantics preserved from the template originals (loop-render.ts, pre-extraction):
|
|
40
|
+
// errText ← __errText · causeChain ← __causeChain · errSnap ← __errSnap
|
|
41
|
+
// classifyFailure ← __classifyFailure · gateVerdict ← __gateVerdict · joinRegion ← joinRegion
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface ErrSnapLink {
|
|
45
|
+
code: string | null;
|
|
46
|
+
name: string | null;
|
|
47
|
+
text: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* TOTAL error-to-text (the ha-consilium 5b totality lesson): the writer's own settle event must
|
|
52
|
+
* survive a hostile error object. `String(err)` throws on a null-prototype object and a throwing
|
|
53
|
+
* `.message` getter throws on access — both are caught here, so rendering a message can never
|
|
54
|
+
* replace the original failure or lose the settle. `.message` is read ONCE into a local (a one-shot
|
|
55
|
+
* getter answered the `typeof` probe and vanished on the value read — snapshot-once defeats it).
|
|
56
|
+
*/
|
|
57
|
+
export function errText(err: unknown): string {
|
|
58
|
+
try {
|
|
59
|
+
if (err !== null && typeof err === 'object') {
|
|
60
|
+
const m = (err as { message?: unknown }).message;
|
|
61
|
+
if (typeof m === 'string') return m;
|
|
62
|
+
}
|
|
63
|
+
return String(err);
|
|
64
|
+
} catch (_e) {
|
|
65
|
+
try {
|
|
66
|
+
return Object.prototype.toString.call(err);
|
|
67
|
+
} catch (_e2) {
|
|
68
|
+
return '[unrenderable error]';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `err.cause` chain, bounded (depth 5), cycle-safe and getter-safe. The standard Node fetch
|
|
75
|
+
* shape `TypeError('fetch failed', { cause: { code: 'ECONNRESET' } })` hides its real class one
|
|
76
|
+
* link down, so classification must see the whole chain, not the outermost error.
|
|
77
|
+
*/
|
|
78
|
+
export function causeChain(err: unknown): unknown[] {
|
|
79
|
+
const chain: unknown[] = [];
|
|
80
|
+
let cur: unknown = err;
|
|
81
|
+
for (let d = 0; d < 5; d++) {
|
|
82
|
+
if (cur === null || cur === undefined) break;
|
|
83
|
+
if (chain.indexOf(cur) !== -1) break; // cycle-safe
|
|
84
|
+
chain.push(cur);
|
|
85
|
+
try {
|
|
86
|
+
cur = typeof cur === 'object' ? (cur as { cause?: unknown }).cause : undefined;
|
|
87
|
+
} catch (_e) {
|
|
88
|
+
cur = undefined; // getter-safe
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return chain.length > 0 ? chain : [err];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ONE snapshot PER FAILURE. The earlier shape snapshotted `.message` once per `errText` CALL, not
|
|
96
|
+
* once per failure — so logging read it, classification read it AGAIN, and a one-shot `.message`
|
|
97
|
+
* getter answered the log and defeated the classifier (2 getter reads, 1 attempt, MEASURED). The
|
|
98
|
+
* catch site builds this snapshot once; the log line and the classifier both consume the SNAPSHOT,
|
|
99
|
+
* so `.code` / `.name` / `.message` are each read exactly once per failure, over the whole chain.
|
|
100
|
+
*/
|
|
101
|
+
export function errSnap(err: unknown): ErrSnapLink[] {
|
|
102
|
+
const chain = causeChain(err);
|
|
103
|
+
const snap: ErrSnapLink[] = [];
|
|
104
|
+
for (let ci = 0; ci < chain.length; ci++) {
|
|
105
|
+
let code: string | null = null;
|
|
106
|
+
try {
|
|
107
|
+
const c = chain[ci] !== null && typeof chain[ci] === 'object' ? (chain[ci] as { code?: unknown }).code : null;
|
|
108
|
+
code = typeof c === 'string' ? c.toUpperCase() : null;
|
|
109
|
+
} catch (_e) {
|
|
110
|
+
code = null;
|
|
111
|
+
}
|
|
112
|
+
let name: string | null = null;
|
|
113
|
+
try {
|
|
114
|
+
const n = chain[ci] !== null && typeof chain[ci] === 'object' ? (chain[ci] as { name?: unknown }).name : null;
|
|
115
|
+
name = typeof n === 'string' ? n : null;
|
|
116
|
+
} catch (_e) {
|
|
117
|
+
name = null;
|
|
118
|
+
}
|
|
119
|
+
snap.push({ code: code, name: name, text: errText(chain[ci]) });
|
|
120
|
+
}
|
|
121
|
+
return snap;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The CLOSED failure classification of `loop-plan/1` (timeout | transport | malformed-output |
|
|
126
|
+
* policy-refusal). THREE TIERS over the whole cause chain, strongest first:
|
|
127
|
+
* 1. error CODE — works on non-Error shapes like `{code:'ECONNRESET'}`, never message-dependent
|
|
128
|
+
* (`ETIMEDOUT` is a TRANSPORT code; an earlier message regex captured it as 'timeout' first);
|
|
129
|
+
* 2. error NAME — `SyntaxError` = parsing the model's output failed → malformed-output;
|
|
130
|
+
* 3. message patterns, DISJOINT by precedence transport > policy-refusal > malformed-output >
|
|
131
|
+
* timeout, every alternative WORD-BOUNDED (an unbounded `rate.?limit` matched
|
|
132
|
+
* 'delibeRATE LIMITation' — a substring must never smuggle a class).
|
|
133
|
+
* `outcome: 'null'` (a dead/empty agent) is a delivery failure ⇒ `transport`, retryable ONLY under
|
|
134
|
+
* `retryOn: ['transport']`. An UNCLASSIFIABLE failure returns null and is NEVER retried.
|
|
135
|
+
*/
|
|
136
|
+
export function classifyFailure(
|
|
137
|
+
outcome: 'null' | 'error',
|
|
138
|
+
snap: ErrSnapLink[],
|
|
139
|
+
): import('./loop-plan.js').FailureClass | null {
|
|
140
|
+
if (outcome === 'null') return 'transport';
|
|
141
|
+
const links = Array.isArray(snap) ? snap : [];
|
|
142
|
+
for (let ci = 0; ci < links.length; ci++) {
|
|
143
|
+
const code = (links[ci] as ErrSnapLink).code;
|
|
144
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EPIPE' || code === 'ECONNABORTED' || code === 'EAI_AGAIN') return 'transport';
|
|
145
|
+
}
|
|
146
|
+
for (let ci = 0; ci < links.length; ci++) {
|
|
147
|
+
if ((links[ci] as ErrSnapLink).name === 'SyntaxError') return 'malformed-output';
|
|
148
|
+
}
|
|
149
|
+
let msg = '';
|
|
150
|
+
for (let ci = 0; ci < links.length; ci++) msg += (ci > 0 ? '\n' : '') + (links[ci] as ErrSnapLink).text;
|
|
151
|
+
msg = msg.toLowerCase();
|
|
152
|
+
// rate[ -]?limit(ed|ing|s)? is RIGHT-BOUNDED: the open 'rate.?limit' matched
|
|
153
|
+
// 'rate limitation: invalid JSON' as transport — a malformed-output failure smuggled a class.
|
|
154
|
+
if (/\btransport\b|\beconnreset\b|\beconnrefused\b|\benotfound\b|\bepipe\b|\betimedout\b|\bsocket hang up\b|\bnetwork error\b|\brate[ -]?limit(ed|ing|s)?\b|\boverloaded\b|\bhttp 5[0-9][0-9]\b/.test(msg)) return 'transport';
|
|
155
|
+
if (/\bpolicy\b|\brefus(e|ed|es|al|ing)\b|\bdeclin(e|ed|es|ing)\b|\bcontent filter\b|\bsafety block\b/.test(msg)) return 'policy-refusal';
|
|
156
|
+
if (/\bmalformed\b|\bunparseable\b|\bparse error\b|\binvalid json\b|\bunexpected token\b|\bunexpected end of json\b|\bschema mismatch\b/.test(msg)) return 'malformed-output';
|
|
157
|
+
if (/\btimeout\b|\btimed out\b/.test(msg)) return 'timeout';
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type GateVerdict = 'pass' | 'fail' | 'invalid';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Gate verdict parsing — parse-NEVER-synthesize, with the EXACTLY-ONE-ENDING-LINE protocol
|
|
165
|
+
* enforced: the verdict must be an ANCHORED line ("GATE: PASS" or "GATE: FAIL" alone on its line),
|
|
166
|
+
* it must be the LAST non-empty line of the reply, and it must be the ONLY anchored verdict line.
|
|
167
|
+
* Embedded mid-reply "GATE: PASS" text never counts, "GATE: PASS" followed by trailing prose is
|
|
168
|
+
* invalid, and "GATE: FAIL … GATE: PASS" is an INVALID verdict (never a success) — routed like a
|
|
169
|
+
* failure (redo / fail route), never a pass.
|
|
170
|
+
*/
|
|
171
|
+
export function gateVerdict(reply: unknown): GateVerdict {
|
|
172
|
+
if (typeof reply !== 'string') return 'invalid';
|
|
173
|
+
const vLines = reply.split('\n');
|
|
174
|
+
const vRe = /^\s*GATE:\s*(PASS|FAIL)\s*$/;
|
|
175
|
+
let vCount = 0;
|
|
176
|
+
let vLast = '';
|
|
177
|
+
for (let i = 0; i < vLines.length; i++) {
|
|
178
|
+
if (vRe.test(vLines[i] as string)) vCount++;
|
|
179
|
+
if ((vLines[i] as string).trim() !== '') vLast = vLines[i] as string;
|
|
180
|
+
}
|
|
181
|
+
const vEnd = vRe.exec(vLast);
|
|
182
|
+
if (vCount !== 1 || vEnd === null) return 'invalid';
|
|
183
|
+
return vEnd[1] === 'PASS' ? 'pass' : 'fail';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface JoinOutcome {
|
|
187
|
+
ok: true;
|
|
188
|
+
values: unknown[];
|
|
189
|
+
failures: number[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The join decision — explicit policy from the closed set; a dispatched branch is never skippable.
|
|
194
|
+
* `any` fails only when EVERY branch failed; `quorum:<n>` needs n non-failing branches; every other
|
|
195
|
+
* policy (the `all-*` family) fails on the first failing branch. Throws with a NAMED message, which
|
|
196
|
+
* the caller settles through its own single terminal exit.
|
|
197
|
+
*/
|
|
198
|
+
export function joinRegion(results: unknown[], o: { policy: string; onInvalid: string; region: string }): JoinOutcome {
|
|
199
|
+
const policy = o && o.policy ? o.policy : 'all-activated';
|
|
200
|
+
const failures: number[] = [];
|
|
201
|
+
for (let i = 0; i < results.length; i++) {
|
|
202
|
+
if (results[i] === null || results[i] === undefined) failures.push(i);
|
|
203
|
+
}
|
|
204
|
+
if (policy === 'any') {
|
|
205
|
+
if (failures.length === results.length) throw new Error('join ' + o.region + ': every branch failed (policy any)');
|
|
206
|
+
return { ok: true, values: results, failures: failures };
|
|
207
|
+
}
|
|
208
|
+
const quorum = /^quorum:([1-9][0-9]*)$/.exec(policy);
|
|
209
|
+
if (quorum) {
|
|
210
|
+
const okN = results.length - failures.length;
|
|
211
|
+
if (okN < Number(quorum[1])) throw new Error('join ' + o.region + ': quorum ' + quorum[1] + ' not met (' + okN + ' ok)');
|
|
212
|
+
return { ok: true, values: results, failures: failures };
|
|
213
|
+
}
|
|
214
|
+
if (failures.length > 0) throw new Error('join ' + o.region + ': ' + failures.length + ' dispatched branch(es) failed under policy ' + policy + ' — a dispatched branch is never skippable');
|
|
215
|
+
return { ok: true, values: results, failures: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
// NON-BLOB EXPORTS — shared by loop-render.ts and workflow-run.ts, never injected into a script
|
|
220
|
+
// (the rendered script receives the RESULT of these as literal text / a literal number).
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export interface ContractInputs {
|
|
224
|
+
reads: string[];
|
|
225
|
+
writes: string[];
|
|
226
|
+
deliverable: Deliverable;
|
|
227
|
+
tools: string[];
|
|
228
|
+
gate: { kind: string } | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* THE agent-visible contract TEXT lines (ADR-001 Confirmation-5) — byte-for-byte the strings the
|
|
233
|
+
* render splices after a step's USER prompt, minus the JS quoting. Both enactors assemble a step's
|
|
234
|
+
* prompt from the SAME function, so a dz-hosted step and a Claude-hosted step communicate the plan's
|
|
235
|
+
* declarations identically; a value-pinned wiring test compares the rendered USER-region contract
|
|
236
|
+
* lines against the runner-assembled ones.
|
|
237
|
+
*
|
|
238
|
+
* The tools line's second sentence is not decoration — it is the honesty clause the whole feature
|
|
239
|
+
* rests on: a declaration is not enforcement.
|
|
240
|
+
*/
|
|
241
|
+
export function stepContractLines(c: ContractInputs): string[] {
|
|
242
|
+
const lines: string[] = [];
|
|
243
|
+
const reads = c.reads ?? [];
|
|
244
|
+
const writes = c.writes ?? [];
|
|
245
|
+
const tools = c.tools ?? [];
|
|
246
|
+
if (reads.length > 0) lines.push('declared inputs (plan artifacts.reads): ' + reads.join(', '));
|
|
247
|
+
if (writes.length > 0) {
|
|
248
|
+
const fileNote = (c.deliverable ?? 'return-value') === 'file' ? '; your deliverable is the written file(s), not your reply' : '';
|
|
249
|
+
lines.push('declared outputs (plan artifacts.writes): ' + writes.join(', ') + ' — write them' + fileNote + '. The loop verifies they land.');
|
|
250
|
+
}
|
|
251
|
+
if (tools.length > 0) {
|
|
252
|
+
lines.push('declared MCP tool allowlist (plan tools): ' + tools.join(', ') + ' — use NOTHING outside it. In this environment every one of these is a labeled STUB, not a live integration; enforcement lives at the MCP server, not here.');
|
|
253
|
+
}
|
|
254
|
+
if (c.gate !== null && c.gate !== undefined) {
|
|
255
|
+
lines.push('GATE PROTOCOL (kind: ' + (c.gate.kind ?? 'gate') + '): end your reply with exactly one line "GATE: PASS" or "GATE: FAIL" — the loop PARSES this verdict and never synthesizes one.');
|
|
256
|
+
}
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* THE budget ceiling formula (ADR-004 Confirmation-2): declared per-step budgets PLUS the declared
|
|
262
|
+
* gate-redo allowance — a plan-declared redo must be AFFORDABLE (an undeclared one still hits the
|
|
263
|
+
* guard loudly). A gate whose failRoute is a `terminal:` route reserves nothing: a terminal route
|
|
264
|
+
* ends the run, it does not re-run anything.
|
|
265
|
+
*
|
|
266
|
+
* This is the number the rendered script carries as `const __budget = { left: N }`; the runner reads
|
|
267
|
+
* it from HERE, so the two enactors cannot drift into two ceilings.
|
|
268
|
+
*/
|
|
269
|
+
export function computeBudgetTotal(plan: LoopPlan): number {
|
|
270
|
+
const stepBudget = plan.steps.reduce((n, s) => n + (s.budget?.maxAgents ?? 1), 0);
|
|
271
|
+
const byId = new Map(plan.steps.map((s) => [s.stepId, s]));
|
|
272
|
+
const gateRedoBudget = (plan.gates ?? []).reduce((n, g) => {
|
|
273
|
+
const redos = typeof g.maxRedos === 'number' && Number.isFinite(g.maxRedos) && g.maxRedos > 0 ? Math.floor(g.maxRedos) : 0;
|
|
274
|
+
if (redos === 0 || typeof g.failRoute !== 'string' || g.failRoute.startsWith('terminal:')) return n;
|
|
275
|
+
return n + redos * ((byId.get(g.failRoute)?.budget?.maxAgents ?? 1) + (byId.get(g.stepId)?.budget?.maxAgents ?? 1));
|
|
276
|
+
}, 0);
|
|
277
|
+
return stepBudget + gateRedoBudget;
|
|
278
|
+
}
|
package/src/loop-trace.ts
CHANGED
|
@@ -211,6 +211,19 @@ export function traceFlushCmd(state: TraceState, traceFileAbs: string): string |
|
|
|
211
211
|
return 'mkdir -p ' + dir + ' && ' + printfs;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Drain the buffered, already-VALIDATED lines — the runner's flush primitive (W17/T0.1). The
|
|
216
|
+
* fs-less Claude host turns the same buffer into a shell command (`traceFlushCmd`); a host that
|
|
217
|
+
* HAS fs (the `dz workflow run` scheduler) appends exactly these lines itself. One buffer, two
|
|
218
|
+
* drains, zero second line-shape: a line this returns has already passed `traceValidateEvent`,
|
|
219
|
+
* because nothing else can enter the buffer. Empty buffer ⇒ `[]` (never a repeat of the last
|
|
220
|
+
* batch).
|
|
221
|
+
*/
|
|
222
|
+
export function traceDrain(state: TraceState): string[] {
|
|
223
|
+
if (state.buffer.length === 0) return [];
|
|
224
|
+
return state.buffer.splice(0, state.buffer.length);
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
/**
|
|
215
228
|
* Build the feature-ADR live-panel telemetry leg. Totality comes from the caller's grouped splice:
|
|
216
229
|
* returning the bare command lets that splice preserve the trace flush's exit status while
|
|
@@ -303,12 +316,31 @@ export interface TraceRun {
|
|
|
303
316
|
parseErrors: string[];
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
319
|
+
/**
|
|
320
|
+
* Parse a trace.jsonl text. Tolerant of a missing run.closed (incomplete: true); a DUPLICATE
|
|
321
|
+
* settle for one invocation is a PARSE ERROR, never a silent merge (INV-15).
|
|
322
|
+
*
|
|
323
|
+
* W17 / AM-12 — `run.events` is CANONICALIZED by ascending `seq` after the line scan. The host's
|
|
324
|
+
* batched racing flush agents legitimately append out of seq order (MEASURED on the committed
|
|
325
|
+
* `pkg-audit-1` run: lines 1-2 are settles seq 7 and 6, ahead of `run.opened` seq 1), and the
|
|
326
|
+
* reader used to inherit that file order — so `invocations()` DROPPED every settle that preceded
|
|
327
|
+
* its own dispatch in the file, and a complete, successful run read as three FAIL verdicts. seq is
|
|
328
|
+
* the authoritative order (it is allocated synchronously at the lifecycle transition); file order
|
|
329
|
+
* is a durability artifact the flush design already blesses, and it stays recoverable only from the
|
|
330
|
+
* raw text.
|
|
331
|
+
*
|
|
332
|
+
* Order of the two bookkeeping passes is load-bearing:
|
|
333
|
+
* • LINE-SHAPE errors (unparseable / invalid event) are recorded in FILE order — they describe
|
|
334
|
+
* the bytes, and quoting them in file order is what lets a human find the line.
|
|
335
|
+
* • The duplicate-dispatch and duplicate-settle checks run AFTER canonicalization, so "which
|
|
336
|
+
* settle is the duplicate" is decided by seq, not by which flush batch happened to land first
|
|
337
|
+
* (before this, reversing the flush order changed WHICH event INV-15 refused).
|
|
338
|
+
* The sort is stable, so two events sharing one seq keep their file order relative to each other —
|
|
339
|
+
* the only ordering the file can still testify to. INV-14 fails such a trace on uniqueness anyway.
|
|
340
|
+
*/
|
|
308
341
|
export function parseTrace(text: string): TraceRun {
|
|
309
342
|
const run: TraceRun = { runId: null, planDigest: null, execFp: null, events: [], incomplete: true, parseErrors: [] };
|
|
310
|
-
const
|
|
311
|
-
const dispatchSeen = new Set<string>();
|
|
343
|
+
const scanned: TraceEvent[] = [];
|
|
312
344
|
for (const line of String(text ?? '').split('\n')) {
|
|
313
345
|
const t = line.trim();
|
|
314
346
|
if (t === '') continue;
|
|
@@ -324,7 +356,13 @@ export function parseTrace(text: string): TraceRun {
|
|
|
324
356
|
run.parseErrors.push('invalid event (' + err + '): ' + t.slice(0, 120));
|
|
325
357
|
continue;
|
|
326
358
|
}
|
|
327
|
-
|
|
359
|
+
scanned.push(e as TraceEvent);
|
|
360
|
+
}
|
|
361
|
+
// CANONICALIZATION (the W17 fix): ascending seq, stable.
|
|
362
|
+
scanned.sort((a, b) => a.seq - b.seq);
|
|
363
|
+
const settledSeen = new Set<string>();
|
|
364
|
+
const dispatchSeen = new Set<string>();
|
|
365
|
+
for (const ev of scanned) {
|
|
328
366
|
if (ev.event === 'run.opened') {
|
|
329
367
|
run.runId = ev.runId;
|
|
330
368
|
run.planDigest = ev.planDigest;
|
|
@@ -395,13 +433,49 @@ export function runInvariants(projection: TraceProjection, run: TraceRun): Invar
|
|
|
395
433
|
const out: InvariantVerdict[] = [];
|
|
396
434
|
const invs = invocations(run);
|
|
397
435
|
|
|
398
|
-
// INV-14
|
|
436
|
+
// INV-14 (RESTATED, W17/AM-12 — verdict id UNCHANGED: `seq-monotonic` is the consumer contract,
|
|
437
|
+
// keyed on by the fitness suite and every `dz workflow-trace` reader, so only the SEMANTICS and
|
|
438
|
+
// the message restate). `parseTrace` now canonicalizes by seq, so "strictly increasing in event
|
|
439
|
+
// order" became tautological — it tested the reader's own sort. The property that still has
|
|
440
|
+
// teeth is the SINGLE-ALLOCATOR witness:
|
|
441
|
+
// • seq values are UNIQUE (two writers, or one writer allocating twice, collide) — always;
|
|
442
|
+
// • on a COMPLETE trace they are exactly contiguous 1..maxSeq (a closed run that skips a
|
|
443
|
+
// number lost an event or had a second allocator);
|
|
444
|
+
// • on an INCOMPLETE trace a gap is INCONCLUSIVE, never a pass — the window is truncated, so
|
|
445
|
+
// a missing number is indistinguishable from an unflushed one.
|
|
446
|
+
// Residue accepted and named in AM-12: a writer allocating unique + contiguous seq in a
|
|
447
|
+
// non-monotonic ORDER is no longer detectable. Allocation order was only ever observable through
|
|
448
|
+
// the racy append that this fix (correctly) stopped trusting.
|
|
399
449
|
{
|
|
400
450
|
const seqs = run.events.map((e) => e.seq);
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
451
|
+
const dupes = [...new Set(seqs.filter((s, i) => seqs.indexOf(s) !== i))].sort((a, b) => a - b);
|
|
452
|
+
if (dupes.length > 0) {
|
|
453
|
+
out.push({
|
|
454
|
+
id: 'seq-monotonic',
|
|
455
|
+
status: 'fail',
|
|
456
|
+
message: 'duplicate seq value(s) ' + dupes.join(', ') + ' — the single-ALLOCATOR property is broken (seq must be unique across the whole run)',
|
|
457
|
+
});
|
|
458
|
+
} else {
|
|
459
|
+
const maxSeq = seqs.length === 0 ? 0 : Math.max(...seqs);
|
|
460
|
+
const missing: number[] = [];
|
|
461
|
+
const present = new Set(seqs);
|
|
462
|
+
for (let s = 1; s <= maxSeq && missing.length < 8; s++) if (!present.has(s)) missing.push(s);
|
|
463
|
+
if (missing.length === 0) {
|
|
464
|
+
out.push({ id: 'seq-monotonic', status: 'pass', message: 'seq unique and contiguous 1..' + maxSeq + ' (' + seqs.length + ' events)' });
|
|
465
|
+
} else if (run.incomplete) {
|
|
466
|
+
out.push({
|
|
467
|
+
id: 'seq-monotonic',
|
|
468
|
+
status: 'inconclusive',
|
|
469
|
+
message: 'seq unique but NOT contiguous (missing ' + missing.join(', ') + ' of 1..' + maxSeq + ') on an INCOMPLETE trace — a truncated window is indistinguishable from a lost event',
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
out.push({
|
|
473
|
+
id: 'seq-monotonic',
|
|
474
|
+
status: 'fail',
|
|
475
|
+
message: 'seq is not contiguous 1..' + maxSeq + ' on a COMPLETE trace — missing ' + missing.join(', ') + ' (a closed run that skips a number lost an event or had a second allocator)',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
405
479
|
}
|
|
406
480
|
|
|
407
481
|
// INV-15: pairing (a dangling dispatch is only conclusive on a complete trace).
|