@dzhechkov/harness-core 0.8.47 → 0.8.49
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 +57 -37
- package/README.md +69 -4
- package/dist/publish.d.ts +1 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +2 -2
- package/dist/release-line.d.ts +1 -6
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +11 -13
- package/dist/release-line.js.map +1 -1
- package/dist/setup-memory-deps.d.ts +14 -0
- package/dist/setup-memory-deps.d.ts.map +1 -0
- package/dist/setup-memory-deps.js +210 -0
- package/dist/setup-memory-deps.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +379 -378
- package/dist/setup.js.map +1 -1
- package/dist/stage-usage.d.ts +102 -2
- package/dist/stage-usage.d.ts.map +1 -1
- package/dist/stage-usage.js +252 -2
- package/dist/stage-usage.js.map +1 -1
- package/dist/test-receipt.d.ts +4 -2
- package/dist/test-receipt.d.ts.map +1 -1
- package/dist/test-receipt.js +97 -3
- package/dist/test-receipt.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +15 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +50 -8
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +8 -1
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +64 -0
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +86 -36
- package/src/publish.ts +3 -3
- package/src/release-line.ts +11 -13
- package/src/setup-memory-deps.ts +166 -0
- package/src/setup.ts +95 -98
- package/src/stage-usage.ts +196 -2
- package/src/test-receipt.ts +90 -4
- package/src/workflow-run-dispatch.ts +50 -5
- package/src/workflow-run.ts +61 -1
package/src/test-receipt.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
/** Одна запись журнала квитанций. Пишется ТОЛЬКО наблюдателем, видевшим код возврата 0. */
|
|
23
23
|
export interface TestReceipt {
|
|
24
|
-
readonly v: 1;
|
|
24
|
+
readonly v: 1 | 2;
|
|
25
25
|
/** Когда прогон закончился, ISO-8601. */
|
|
26
26
|
readonly ts: string;
|
|
27
27
|
/** Что именно было запущено — для сообщения об отказе, чтобы читатель мог повторить. */
|
|
@@ -39,7 +39,8 @@ export interface TestReceiptScopeState {
|
|
|
39
39
|
* `never` — область не встречается ни в одной квитанции: прогона не было вовсе.
|
|
40
40
|
* `changed` — квитанции есть, но ни одна не покрывает текущее содержимое.
|
|
41
41
|
*/
|
|
42
|
-
readonly why: 'never' | 'changed';
|
|
42
|
+
readonly why: 'never' | 'changed' | 'unobserved';
|
|
43
|
+
readonly unobservedReasons?: readonly string[];
|
|
43
44
|
/** Время самой свежей квитанции, покрывавшей эту область в ЛЮБОМ виде; null при `never`. */
|
|
44
45
|
readonly lastGreenAt: string | null;
|
|
45
46
|
}
|
|
@@ -49,6 +50,76 @@ export type TestReceiptVerdict =
|
|
|
49
50
|
| { readonly state: 'stale'; readonly uncovered: readonly TestReceiptScopeState[] }
|
|
50
51
|
| { readonly state: 'not-established'; readonly reason: string };
|
|
51
52
|
|
|
53
|
+
const canonicalPath = (path: unknown): path is string => typeof path === 'string' &&
|
|
54
|
+
!/^[A-Za-z]:|[\\\x00-\x1f\x7f-\x9f]/.test(path) && path.split('/').every((p) => p !== '' && p !== '.' && p !== '..');
|
|
55
|
+
const canonicalScope = (scope: string) => ['tests', 'scripts', '.githooks'].includes(scope) ||
|
|
56
|
+
(canonicalPath(scope) && /^packages\/[^/]+(?:\/[^/]+)?$/.test(scope));
|
|
57
|
+
const rootAssociations: Readonly<Record<string, readonly string[]>> = {
|
|
58
|
+
scripts: ['tests/test-receipt-script.test.mjs', 'tests/prepush-receipt-gate.test.mjs'],
|
|
59
|
+
'.githooks': ['tests/prepush-receipt-gate.test.mjs', 'tests/prepush-drift-gate.test.mjs'],
|
|
60
|
+
};
|
|
61
|
+
export function receiptTestPathMatchesScope(scope: string, path: string): boolean {
|
|
62
|
+
if (!canonicalScope(scope) || !canonicalPath(path)) return false;
|
|
63
|
+
if (scope === 'tests') return path.startsWith('tests/');
|
|
64
|
+
if (rootAssociations[scope]) return rootAssociations[scope]!.includes(path);
|
|
65
|
+
return ['src', 'test', 'tests'].some((dir) => path.startsWith(`${scope}/${dir}/`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const positive = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) > 0;
|
|
69
|
+
const object = (v: any): boolean => v !== null && typeof v === 'object' &&
|
|
70
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(v)) && Reflect.ownKeys(v).every((k) =>
|
|
71
|
+
typeof k === 'string' && Object.getOwnPropertyDescriptor(v, k)?.enumerable === true);
|
|
72
|
+
const keys = (v: any, expected: string[]): boolean => object(v) &&
|
|
73
|
+
Object.keys(v).length === expected.length && expected.every((k) => Object.hasOwn(v, k));
|
|
74
|
+
const array = (v: any): v is any[] => Array.isArray(v) &&
|
|
75
|
+
Reflect.ownKeys(v).length === v.length + 1 && Object.keys(v).length === v.length && Object.keys(v).every((k, i) => k === String(i));
|
|
76
|
+
function observed(receipt: any): boolean {
|
|
77
|
+
if (!keys(receipt, ['v', 'ts', 'command', 'observationId', 'schema', 'scopes', 'segments', 'evidence']) ||
|
|
78
|
+
receipt.v !== 2 || receipt.schema !== 'observed-test-execution-1' ||
|
|
79
|
+
typeof receipt.ts !== 'string' || !Number.isFinite(Date.parse(receipt.ts)) ||
|
|
80
|
+
new Date(receipt.ts).toISOString() !== receipt.ts || typeof receipt.command !== 'string' || !receipt.command ||
|
|
81
|
+
typeof receipt.observationId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(receipt.observationId) ||
|
|
82
|
+
!object(receipt.scopes) || !object(receipt.evidence)) return false;
|
|
83
|
+
const scopes = Object.keys(receipt.scopes);
|
|
84
|
+
if (!scopes.length || !keys(receipt.evidence, scopes) || !scopes.every((scope) =>
|
|
85
|
+
canonicalScope(scope) && typeof receipt.scopes[scope] === 'string' && receipt.scopes[scope] !== '')) return false;
|
|
86
|
+
if (!array(receipt.segments) || !receipt.segments.length) return false;
|
|
87
|
+
const files: Map<string, number>[] = [];
|
|
88
|
+
for (const segment of receipt.segments) {
|
|
89
|
+
if (!keys(segment, ['runner', 'complete', 'failed', 'files']) ||
|
|
90
|
+
!['node-test', 'vitest'].includes(segment.runner) || segment.complete !== true || segment.failed !== 0 ||
|
|
91
|
+
!array(segment.files) || !segment.files.length) return false;
|
|
92
|
+
const counts = new Map<string, number>();
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const file of segment.files) {
|
|
95
|
+
if (!keys(file, ['path', 'executed']) || !canonicalPath(file.path) || !positive(file.executed) || counts.has(file.path)) return false;
|
|
96
|
+
counts.set(file.path, file.executed);
|
|
97
|
+
total += file.executed;
|
|
98
|
+
if (!Number.isSafeInteger(total)) return false;
|
|
99
|
+
}
|
|
100
|
+
files.push(counts);
|
|
101
|
+
}
|
|
102
|
+
return scopes.every((scope) => {
|
|
103
|
+
const evidence = receipt.evidence[scope];
|
|
104
|
+
if (!keys(evidence, ['executed', 'segments']) || !positive(evidence.executed) ||
|
|
105
|
+
!array(evidence.segments) || !evidence.segments.length) return false;
|
|
106
|
+
const indices = new Set<number>();
|
|
107
|
+
let total = 0;
|
|
108
|
+
for (const contribution of evidence.segments) {
|
|
109
|
+
if (!keys(contribution, ['index', 'paths']) || !Number.isSafeInteger(contribution.index) ||
|
|
110
|
+
contribution.index < 0 || contribution.index >= files.length || indices.has(contribution.index) ||
|
|
111
|
+
!array(contribution.paths) || !contribution.paths.length || new Set(contribution.paths).size !== contribution.paths.length) return false;
|
|
112
|
+
indices.add(contribution.index);
|
|
113
|
+
for (const path of contribution.paths) {
|
|
114
|
+
if (!canonicalPath(path) || !receiptTestPathMatchesScope(scope, path) || !files[contribution.index]!.has(path)) return false;
|
|
115
|
+
total += files[contribution.index]!.get(path)!;
|
|
116
|
+
if (!Number.isSafeInteger(total)) return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return total === evidence.executed;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
52
123
|
/**
|
|
53
124
|
* Чистое решение. Ничего не читает и не запускает — отпечатки и журнал подаёт вызывающий.
|
|
54
125
|
*
|
|
@@ -79,13 +150,19 @@ export function decideTestReceipt(input: {
|
|
|
79
150
|
const digest = current[scope]!;
|
|
80
151
|
let seenAt: string | null = null;
|
|
81
152
|
let covered = false;
|
|
153
|
+
const unobservedReasons = new Set<string>();
|
|
82
154
|
for (const receipt of receipts) {
|
|
83
155
|
const recorded = receipt.scopes[scope];
|
|
84
156
|
if (typeof recorded !== 'string') continue;
|
|
157
|
+
if (!observed(receipt)) {
|
|
158
|
+
unobservedReasons.add(receipt.v === 1 ? 'legacy' : receipt.v !== 2 ? 'unsupported-version' : 'invalid-v2');
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
85
161
|
if (typeof receipt.ts === 'string' && (seenAt === null || receipt.ts > seenAt)) seenAt = receipt.ts;
|
|
86
162
|
if (recorded === digest) { covered = true; break; }
|
|
87
163
|
}
|
|
88
|
-
if (!covered) uncovered.push({ scope, digest, why: seenAt
|
|
164
|
+
if (!covered) uncovered.push({ scope, digest, why: seenAt !== null ? 'changed' : unobservedReasons.size ? 'unobserved' : 'never',
|
|
165
|
+
lastGreenAt: seenAt, ...(seenAt === null && unobservedReasons.size ? { unobservedReasons: [...unobservedReasons].sort() } : {}) });
|
|
89
166
|
}
|
|
90
167
|
|
|
91
168
|
return uncovered.length === 0 ? { state: 'covered', scopes } : { state: 'stale', uncovered };
|
|
@@ -101,6 +178,15 @@ export function renderTestReceiptVerdict(verdict: TestReceiptVerdict): string {
|
|
|
101
178
|
}
|
|
102
179
|
const lines = verdict.uncovered.map((item) => item.why === 'never'
|
|
103
180
|
? ` · ${item.scope}: зелёного прогона не было НИ РАЗУ`
|
|
181
|
+
: item.why === 'unobserved' ? ` · ${item.scope}: missing observation — история без подтверждённого исполнения (${item.unobservedReasons?.join(', ')})`
|
|
104
182
|
: ` · ${item.scope}: изменилась после последнего зелёного прогона (${item.lastGreenAt})`);
|
|
105
|
-
|
|
183
|
+
const hints = verdict.uncovered.map(({ scope }) => {
|
|
184
|
+
if (scope.startsWith('packages/')) {
|
|
185
|
+
const wrapper = `${'../'.repeat(scope.split('/').length)}scripts/with-test-receipt.sh`;
|
|
186
|
+
return ` rerun from ${scope}: ${wrapper} ${scope} -- npx vitest run <test-file>; for Node tests use ${wrapper} ${scope} -- node --test <test-file>`;
|
|
187
|
+
}
|
|
188
|
+
const file = scope === 'scripts' ? 'tests/test-receipt-script.test.mjs' : 'tests/prepush-receipt-gate.test.mjs';
|
|
189
|
+
return ` rerun from repository root: scripts/with-test-receipt.sh ${scope} -- node --test ${file}`;
|
|
190
|
+
});
|
|
191
|
+
return [`test-receipt: НЕ ПОКРЫТО — ${verdict.uncovered.length} област(и):`, ...lines, ...hints].join('\n');
|
|
106
192
|
}
|
|
@@ -80,6 +80,13 @@ export interface ProbeOutcome {
|
|
|
80
80
|
id: string | null;
|
|
81
81
|
wallMs: number;
|
|
82
82
|
detail: string;
|
|
83
|
+
provenance?: {
|
|
84
|
+
schema: 'wf-probe-attempts-1'; complete: boolean; totalConsidered: number;
|
|
85
|
+
attempts: Array<{ ordinal: number; model: string | null; family: BridgeFamily; wrapperInvoked: boolean;
|
|
86
|
+
outcome: 'answered' | 'failed' | 'rejected'; selected: boolean;
|
|
87
|
+
reason: 'answered' | 'timeout' | 'spawn-error' | 'no-exit-code' | 'exit-nonzero' | 'unexpected-response' | 'invalid-candidate' }>;
|
|
88
|
+
} | null;
|
|
89
|
+
provenanceReason?: 'candidate-model-invalid' | 'wrapper-result-invalid' | null;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
export interface Dispatcher {
|
|
@@ -342,6 +349,36 @@ function codexStructuredOutput(stdout: string): { structured: boolean; valid: bo
|
|
|
342
349
|
return { structured: true, valid: !malformed && !failedTerminal, text, usage: result, reportedModel: reportedModels.size === 1 ? [...reportedModels][0]! : null };
|
|
343
350
|
}
|
|
344
351
|
|
|
352
|
+
/** Observe the existing wrapper seam; this does not attest OS child start. */
|
|
353
|
+
function collectProbeMetadata(family: BridgeFamily) {
|
|
354
|
+
const attempts: NonNullable<ProbeOutcome['provenance']>['attempts'] = [];
|
|
355
|
+
let totalConsidered = 0;
|
|
356
|
+
let invalid: Exclude<ProbeOutcome['provenanceReason'], undefined> = null;
|
|
357
|
+
return {
|
|
358
|
+
record(id: string, wrapperInvoked: boolean, r: ChildRun | null, selected: boolean) {
|
|
359
|
+
const ordinal = ++totalConsidered;
|
|
360
|
+
if (wrapperInvoked && (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(id))) invalid = 'candidate-model-invalid';
|
|
361
|
+
let reason: NonNullable<ProbeOutcome['provenance']>['attempts'][number]['reason'] = 'invalid-candidate';
|
|
362
|
+
if (r !== null) {
|
|
363
|
+
const typed = typeof r.stdout === 'string' && typeof r.stderr === 'string' && typeof r.timedOut === 'boolean'
|
|
364
|
+
&& (r.exitCode === null || (Number.isSafeInteger(r.exitCode) && r.exitCode >= 0))
|
|
365
|
+
&& (r.spawnError === null || (typeof r.spawnError === 'string' && r.spawnError.length > 0));
|
|
366
|
+
const consistent = typed && !(r.timedOut && (r.spawnError !== null || r.exitCode === 0))
|
|
367
|
+
&& !(r.spawnError !== null && r.exitCode !== null) && !(selected && (r.timedOut || r.spawnError !== null || r.exitCode !== 0));
|
|
368
|
+
if (!consistent && invalid !== 'candidate-model-invalid') invalid = 'wrapper-result-invalid';
|
|
369
|
+
reason = r.timedOut ? 'timeout' : r.spawnError !== null ? 'spawn-error' : r.exitCode === null ? 'no-exit-code'
|
|
370
|
+
: r.exitCode !== 0 ? 'exit-nonzero' : selected ? 'answered' : 'unexpected-response';
|
|
371
|
+
}
|
|
372
|
+
if (ordinal <= 32) attempts.push({ ordinal, model: wrapperInvoked ? id : null, family, wrapperInvoked,
|
|
373
|
+
outcome: !wrapperInvoked ? 'rejected' : selected ? 'answered' : 'failed', reason, selected });
|
|
374
|
+
},
|
|
375
|
+
finish(): Pick<ProbeOutcome, 'provenance' | 'provenanceReason'> {
|
|
376
|
+
return { provenance: invalid === null ? { schema: 'wf-probe-attempts-1', complete: totalConsidered <= 32, totalConsidered, attempts } : null,
|
|
377
|
+
provenanceReason: invalid };
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
345
382
|
// ── the adapter factories (pure over the injected ChildRunner) ───────────────────────────────────
|
|
346
383
|
|
|
347
384
|
function failed(family: BridgeFamily, model: string | null, wallMs: number, reason: DispatchReason, detail: string, outcome: 'null' | 'error' = 'null'): DispatchResult {
|
|
@@ -381,12 +418,16 @@ export function makeCodexExecDispatcher(run: ChildRunner, opts?: { bin?: string;
|
|
|
381
418
|
const t0 = clock();
|
|
382
419
|
const list = candidates.length > 0 ? candidates : ['gpt-5.5'];
|
|
383
420
|
const started: string[] = [];
|
|
421
|
+
const observation = collectProbeMetadata('openai');
|
|
384
422
|
for (const id of list) {
|
|
423
|
+
const wrapperInvoked = true;
|
|
385
424
|
const r = await run(bin, codexProbeArgv(id), { stdinText: null, timeoutMs: 120_000, cwd: isolatedCwd(), detached: true });
|
|
386
425
|
started.push(id);
|
|
387
|
-
|
|
426
|
+
const selected = interpretCodexProbe(r);
|
|
427
|
+
observation.record(id, wrapperInvoked, r, selected);
|
|
428
|
+
if (selected) return { id, wallMs: clock() - t0, detail: `codex answered on ${id}`, ...observation.finish() };
|
|
388
429
|
}
|
|
389
|
-
return { id: null, wallMs: clock() - t0, detail: `no codex candidate answered a probe (tried: ${started.join(', ')}) — an allowlist says an id is spellable, only a probe says it answers
|
|
430
|
+
return { id: null, wallMs: clock() - t0, detail: `no codex candidate answered a probe (tried: ${started.join(', ')}) — an allowlist says an id is spellable, only a probe says it answers`, ...observation.finish() };
|
|
390
431
|
},
|
|
391
432
|
dispatch: async (req) => {
|
|
392
433
|
const t0 = clock();
|
|
@@ -435,14 +476,18 @@ export function makeClaudePDispatcher(run: ChildRunner, opts?: { bin?: string; i
|
|
|
435
476
|
const t0 = clock();
|
|
436
477
|
const list = candidates.length > 0 ? candidates : ['sonnet'];
|
|
437
478
|
const tried: string[] = [];
|
|
479
|
+
const observation = collectProbeMetadata('claude');
|
|
438
480
|
for (const id of list) {
|
|
439
481
|
const argv = claudeProbeArgs(id);
|
|
440
482
|
tried.push(id);
|
|
441
|
-
if (argv === null)
|
|
483
|
+
if (argv === null) { observation.record(id, false, null, false); continue; } // existing unsafe rejection
|
|
484
|
+
const wrapperInvoked = true;
|
|
442
485
|
const r = await run(bin, argv, { stdinText: null, timeoutMs: 120_000, cwd: isolatedCwd(), detached: true });
|
|
443
|
-
|
|
486
|
+
const selected = interpretClaudeProbe({ stdout: r.stdout, exitCode: r.exitCode ?? 1 });
|
|
487
|
+
observation.record(id, wrapperInvoked, r, selected);
|
|
488
|
+
if (selected) return { id, wallMs: clock() - t0, detail: `claude answered on ${id}`, ...observation.finish() };
|
|
444
489
|
}
|
|
445
|
-
return { id: null, wallMs: clock() - t0, detail: `no claude candidate answered a probe (tried: ${tried.join(', ')})
|
|
490
|
+
return { id: null, wallMs: clock() - t0, detail: `no claude candidate answered a probe (tried: ${tried.join(', ')})`, ...observation.finish() };
|
|
446
491
|
},
|
|
447
492
|
dispatch: async (req) => {
|
|
448
493
|
const t0 = clock();
|
package/src/workflow-run.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { classifyFailure, errSnap, gateVerdict, joinRegion, stepContractLines, t
|
|
|
43
43
|
import { checkpointInputHash, decideCheckpointResume, parseCheckpointRead, serializeCheckpoint } from './feature-adr-checkpoints.js';
|
|
44
44
|
import { modelFamily, type BridgeFamily } from './qe-bridge.js';
|
|
45
45
|
import { buildReqeDebt } from './reqe.js';
|
|
46
|
-
import { CODEX_EXEC_XHIGH_TIMEOUT_MS, defangGateEchoes, type DispatchResult, type Dispatcher } from './workflow-run-dispatch.js';
|
|
46
|
+
import { CODEX_EXEC_XHIGH_TIMEOUT_MS, defangGateEchoes, type DispatchResult, type Dispatcher, type ProbeOutcome } from './workflow-run-dispatch.js';
|
|
47
47
|
|
|
48
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
49
|
// Schemas / constants
|
|
@@ -178,6 +178,12 @@ export interface WfBudgetRow extends Pick<DispatchResult, 'tokensTotal' | 'token
|
|
|
178
178
|
model: string | null;
|
|
179
179
|
modelProvenance?: string;
|
|
180
180
|
requestedModel?: string | null;
|
|
181
|
+
plannedModel?: string | null;
|
|
182
|
+
plannedModelSource?: 'plan-declared' | 'plan-omitted' | 'unavailable';
|
|
183
|
+
probeId?: string | null;
|
|
184
|
+
probeProvenance?: Exclude<ProbeOutcome['provenance'], undefined>;
|
|
185
|
+
probeSource?: 'dispatcher-child-seam' | 'scripted-dispatcher';
|
|
186
|
+
probeObservationReason?: 'producer-not-recorded' | 'id-factory-missing' | 'id-factory-invalid' | 'provenance-invalid' | 'candidate-model-invalid' | 'wrapper-result-invalid' | 'selected-model-invalid' | null;
|
|
181
187
|
wallMs: number;
|
|
182
188
|
tokensIn: number | null;
|
|
183
189
|
tokensOut: number | null;
|
|
@@ -936,6 +942,35 @@ export interface RunStore {
|
|
|
936
942
|
writeReqeDebt(record: object): void;
|
|
937
943
|
}
|
|
938
944
|
|
|
945
|
+
const safeRoutingModel = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value);
|
|
946
|
+
function probeRecordReason(probe: ProbeOutcome, family: BridgeFamily): Exclude<WfBudgetRow['probeObservationReason'], undefined> {
|
|
947
|
+
const exact = (value: unknown, keys: string[]): value is Record<string, unknown> => value !== null && typeof value === 'object'
|
|
948
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
949
|
+
&& Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
950
|
+
if (probe.id !== null && !safeRoutingModel(probe.id)) return 'selected-model-invalid';
|
|
951
|
+
const present = [Object.hasOwn(probe, 'provenance'), Object.hasOwn(probe, 'provenanceReason')];
|
|
952
|
+
if (present.every(value => !value)) return 'producer-not-recorded';
|
|
953
|
+
if (!present.every(Boolean)) return 'provenance-invalid';
|
|
954
|
+
if (probe.provenance === null && (probe.provenanceReason === 'candidate-model-invalid' || probe.provenanceReason === 'wrapper-result-invalid')) return probe.provenanceReason;
|
|
955
|
+
const p = probe.provenance;
|
|
956
|
+
if (probe.provenanceReason !== null || !exact(p, ['schema', 'complete', 'totalConsidered', 'attempts']) || p.schema !== 'wf-probe-attempts-1'
|
|
957
|
+
|| typeof p.complete !== 'boolean' || !Number.isSafeInteger(p.totalConsidered) || p.totalConsidered <= 0 || !Array.isArray(p.attempts)
|
|
958
|
+
|| p.attempts.length !== Math.min(p.totalConsidered, 32) || p.complete !== (p.totalConsidered <= 32)) return 'provenance-invalid';
|
|
959
|
+
for (const [index, a] of p.attempts.entries()) {
|
|
960
|
+
if (!exact(a, ['ordinal', 'model', 'family', 'wrapperInvoked', 'outcome', 'reason', 'selected']) || a.ordinal !== index + 1
|
|
961
|
+
|| a.family !== family || typeof a.wrapperInvoked !== 'boolean' || typeof a.selected !== 'boolean') return 'provenance-invalid';
|
|
962
|
+
const rejected = family === 'claude' && !a.wrapperInvoked && !a.selected && a.model === null && a.outcome === 'rejected' && a.reason === 'invalid-candidate';
|
|
963
|
+
const answered = a.wrapperInvoked && a.selected && safeRoutingModel(a.model) && a.outcome === 'answered' && a.reason === 'answered';
|
|
964
|
+
const failed = a.wrapperInvoked && !a.selected && safeRoutingModel(a.model) && a.outcome === 'failed'
|
|
965
|
+
&& ['timeout', 'spawn-error', 'no-exit-code', 'exit-nonzero', 'unexpected-response'].includes(a.reason);
|
|
966
|
+
if (!(rejected || answered || failed)) return 'provenance-invalid';
|
|
967
|
+
}
|
|
968
|
+
const selected = p.attempts.filter(a => a.selected);
|
|
969
|
+
if (!p.complete ? selected.length !== 0 : probe.id === null ? selected.length !== 0
|
|
970
|
+
: selected.length !== 1 || selected[0]?.ordinal !== p.totalConsidered || selected[0]?.model !== probe.id) return 'provenance-invalid';
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
|
|
939
974
|
export interface SchedulerDeps {
|
|
940
975
|
store: RunStore;
|
|
941
976
|
dispatchers: Record<BridgeFamily, Dispatcher>;
|
|
@@ -944,6 +979,7 @@ export interface SchedulerDeps {
|
|
|
944
979
|
/** Injected ISO clock (determinism, NFR-2). */
|
|
945
980
|
now(): string;
|
|
946
981
|
monotonicMs(): number;
|
|
982
|
+
newProbeId?: () => string;
|
|
947
983
|
/**
|
|
948
984
|
* TEST SEAM (named, never a casual flag): disables the landed barrier so the F5 mutant can show
|
|
949
985
|
* the lying file-step passing. Default false; a run that sets it records `dispatcherOverride`.
|
|
@@ -987,6 +1023,7 @@ function failureClassOf(res: DispatchResult): FailureClass | null {
|
|
|
987
1023
|
}
|
|
988
1024
|
|
|
989
1025
|
interface RunCtx {
|
|
1026
|
+
probeIds: Partial<Record<BridgeFamily, string | null>>;
|
|
990
1027
|
inputs: RunnerInputs;
|
|
991
1028
|
pre: PreflightOk;
|
|
992
1029
|
deps: SchedulerDeps;
|
|
@@ -1062,15 +1099,34 @@ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps:
|
|
|
1062
1099
|
|
|
1063
1100
|
const usedFamilies = [...new Set(Object.values(pre.families))];
|
|
1064
1101
|
const probedIds: Partial<Record<BridgeFamily, string | null>> = {};
|
|
1102
|
+
const probeIds: Partial<Record<BridgeFamily, string | null>> = {};
|
|
1065
1103
|
let probeAgentCalls = 0;
|
|
1066
1104
|
const probeDetail: Partial<Record<BridgeFamily, string>> = {};
|
|
1067
1105
|
for (const family of usedFamilies) {
|
|
1068
1106
|
const candidates = [...new Set(allSpecs(pre.projection).filter((s) => pre.families[s.stepId] === family).map((s) => s.model).filter((m): m is string => typeof m === 'string'))];
|
|
1069
1107
|
const probe = await deps.dispatchers[family].probe(candidates);
|
|
1070
1108
|
probeAgentCalls++;
|
|
1109
|
+
let probeId: string | null = null;
|
|
1110
|
+
let idReason: Exclude<WfBudgetRow['probeObservationReason'], undefined> = 'id-factory-missing';
|
|
1111
|
+
if (deps.newProbeId) {
|
|
1112
|
+
try {
|
|
1113
|
+
const id = deps.newProbeId();
|
|
1114
|
+
if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id)) {
|
|
1115
|
+
probeId = id;
|
|
1116
|
+
idReason = null;
|
|
1117
|
+
} else idReason = 'id-factory-invalid';
|
|
1118
|
+
} catch { idReason = 'id-factory-invalid'; }
|
|
1119
|
+
}
|
|
1120
|
+
const producerReason = probeRecordReason(probe, family);
|
|
1121
|
+
const probeObservationReason = producerReason && producerReason !== 'producer-not-recorded' ? producerReason : idReason ?? producerReason;
|
|
1122
|
+
probeIds[family] = probeId;
|
|
1071
1123
|
store.appendBudgetRow({
|
|
1072
1124
|
schema: WF_BUDGET_ROW_SCHEMA,
|
|
1073
1125
|
kind: 'probe',
|
|
1126
|
+
probeId,
|
|
1127
|
+
probeProvenance: probeObservationReason === null ? probe.provenance! : null,
|
|
1128
|
+
probeSource: deps.dispatcherOverride ? 'scripted-dispatcher' : 'dispatcher-child-seam',
|
|
1129
|
+
probeObservationReason,
|
|
1074
1130
|
runId: inputs.runId,
|
|
1075
1131
|
projectRoot: inputs.projectRoot ?? inputs.cwdRoot,
|
|
1076
1132
|
dispatchSeq: null,
|
|
@@ -1207,6 +1263,7 @@ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps:
|
|
|
1207
1263
|
|
|
1208
1264
|
const opened = openTrace(inputs, pre, store, inputs.resume !== null);
|
|
1209
1265
|
const ctx: RunCtx = {
|
|
1266
|
+
probeIds,
|
|
1210
1267
|
inputs,
|
|
1211
1268
|
pre,
|
|
1212
1269
|
deps,
|
|
@@ -1526,6 +1583,9 @@ async function dispatchOnce(
|
|
|
1526
1583
|
family,
|
|
1527
1584
|
model: res.modelUsed,
|
|
1528
1585
|
requestedModel: model,
|
|
1586
|
+
plannedModel: safeRoutingModel(spec.model) ? spec.model : null,
|
|
1587
|
+
plannedModelSource: safeRoutingModel(spec.model) ? 'plan-declared' : spec.model == null ? 'plan-omitted' : 'unavailable',
|
|
1588
|
+
probeId: ctx.probeIds[family] ?? null,
|
|
1529
1589
|
modelProvenance: res.modelProvenance ?? (res.modelUsed === null ? 'not-recorded' : 'dispatcher-reported'),
|
|
1530
1590
|
wallMs,
|
|
1531
1591
|
tokensIn: res.tokensIn,
|