@skill-harness/core 0.30.0 → 0.31.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/dist/authoring-preflight.d.ts +9 -1
- package/dist/authoring-preflight.js +57 -4
- package/dist/authoring.js +11 -2
- package/dist/calibration.d.ts +57 -0
- package/dist/calibration.js +200 -60
- package/dist/canary.d.ts +28 -26
- package/dist/canary.js +109 -92
- package/dist/diagnose.d.ts +2 -0
- package/dist/diagnose.js +12 -3
- package/dist/grade.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/journal.d.ts +3 -0
- package/dist/lint.js +2 -2
- package/dist/regate.js +1 -0
- package/dist/regrade.d.ts +3 -1
- package/dist/regrade.js +90 -73
- package/dist/rescore.js +1 -0
- package/dist/resource-monitor.d.ts +3 -0
- package/dist/resource-monitor.js +72 -0
- package/dist/results.d.ts +9 -4
- package/dist/results.js +4 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +128 -108
- package/dist/saved-run.d.ts +15 -0
- package/dist/saved-run.js +114 -0
- package/dist/scheduler.d.ts +3 -5
- package/dist/scheduler.js +18 -6
- package/dist/sources.js +13 -34
- package/dist/util/exec.d.ts +5 -0
- package/dist/util/exec.js +16 -32
- package/dist/util/fixture-inventory.d.ts +19 -0
- package/dist/util/fixture-inventory.js +68 -0
- package/dist/util/process.d.ts +78 -0
- package/dist/util/process.js +394 -0
- package/dist/workspace.js +13 -29
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getProcessSignal, withProcessScope } from "./util/process.js";
|
|
5
|
+
/** Per-operation metadata only, with bounded retention and low-frequency atomic snapshots. */
|
|
6
|
+
export async function withResourceMonitoring(directory, work, options = {}) {
|
|
7
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
8
|
+
const id = randomUUID();
|
|
9
|
+
const path = join(directory, `resources-${id}.json`), pending = path + ".tmp";
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const inherited = options.signal ?? getProcessSignal();
|
|
12
|
+
const abort = () => controller.abort(inherited?.reason);
|
|
13
|
+
if (inherited?.aborted)
|
|
14
|
+
abort();
|
|
15
|
+
inherited?.addEventListener("abort", abort, { once: true });
|
|
16
|
+
const records = [];
|
|
17
|
+
const active = new Map();
|
|
18
|
+
let total = 0, dropped = 0, droppedActive = 0, state = "running", flushError;
|
|
19
|
+
const started = new Date().toISOString();
|
|
20
|
+
const flush = () => {
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(pending, JSON.stringify({ schema: 1, scope: "harness-owned-processes", started, updated: new Date().toISOString(), state,
|
|
23
|
+
hostHealth: "unmeasured", sampling: "one-second-owned-tree", totalProcesses: total, droppedObservations: dropped, droppedActiveObservations: droppedActive, active: [...active.values()], observations: records }, null, 2) + "\n", { mode: 0o600 });
|
|
24
|
+
renameSync(pending, path);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
flushError = error;
|
|
28
|
+
controller.abort(error);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
flush();
|
|
32
|
+
if (flushError) {
|
|
33
|
+
inherited?.removeEventListener("abort", abort);
|
|
34
|
+
throw flushError;
|
|
35
|
+
}
|
|
36
|
+
const timer = setInterval(flush, 5000);
|
|
37
|
+
timer.unref();
|
|
38
|
+
try {
|
|
39
|
+
return await withProcessScope({ ...options, signal: controller.signal, onSample: record => {
|
|
40
|
+
if (record.pid !== null && (active.has(record.pid) || active.size < 256))
|
|
41
|
+
active.set(record.pid, record);
|
|
42
|
+
else
|
|
43
|
+
droppedActive++;
|
|
44
|
+
options.onSample?.(record);
|
|
45
|
+
}, onObservation: record => {
|
|
46
|
+
total++;
|
|
47
|
+
if (record.pid !== null)
|
|
48
|
+
active.delete(record.pid);
|
|
49
|
+
if (records.length < 256)
|
|
50
|
+
records.push(record);
|
|
51
|
+
else
|
|
52
|
+
dropped++;
|
|
53
|
+
if (record.reason === "resource-limit" || record.reason === "output-limit")
|
|
54
|
+
controller.abort(new Error(record.reason));
|
|
55
|
+
options.onObservation?.(record);
|
|
56
|
+
} }, work);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
state = "interrupted";
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
clearInterval(timer);
|
|
64
|
+
inherited?.removeEventListener("abort", abort);
|
|
65
|
+
if (state === "running")
|
|
66
|
+
state = "settled";
|
|
67
|
+
flush();
|
|
68
|
+
if (flushError)
|
|
69
|
+
throw new Error(`Resource summary could not be retained: ${String(flushError)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=resource-monitor.js.map
|
package/dist/results.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ScenarioVerdict } from "./score.js";
|
|
|
3
3
|
import type { Verdict } from "./score.js";
|
|
4
4
|
import type { ShipBar, Scenario } from "./spec.js";
|
|
5
5
|
import type { CostSource } from "./capture-trace-types.js";
|
|
6
|
+
import type { CanaryObservations } from "./canary.js";
|
|
6
7
|
export interface SubjectUsage {
|
|
7
8
|
repetition: number;
|
|
8
9
|
inputTokens: number | null;
|
|
@@ -181,10 +182,10 @@ export interface ResultsFile {
|
|
|
181
182
|
*/
|
|
182
183
|
harness_cli_version?: string;
|
|
183
184
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* Compatibility status for the optional instructed loading/quote probe.
|
|
186
|
+
* See delivery_observations for what was actually observed; a quote by itself
|
|
187
|
+
* does not establish loading, and the probe never tests autonomous selection.
|
|
188
|
+
* `skipped` means the skill has no `## ` heading. Absent means the probe was not asked
|
|
188
189
|
* for — never that it failed, because a failed canary aborts the run and no
|
|
189
190
|
* results.yaml is written.
|
|
190
191
|
*
|
|
@@ -199,6 +200,10 @@ export interface ResultsFile {
|
|
|
199
200
|
* gitignored, and this claim has to survive a commit.
|
|
200
201
|
*/
|
|
201
202
|
delivery_canary?: "pass" | "skipped";
|
|
203
|
+
/** Optional for historical records; never inferred from a legacy pass alone. */
|
|
204
|
+
delivery_observations?: CanaryObservations & {
|
|
205
|
+
probeCalls: 0 | 1;
|
|
206
|
+
};
|
|
202
207
|
skill: string;
|
|
203
208
|
harness: string;
|
|
204
209
|
model: string;
|
package/dist/results.js
CHANGED
|
@@ -224,6 +224,7 @@ export function finalizeResults(draft, ctx) {
|
|
|
224
224
|
// not report a version must not look like one that reported "nothing".
|
|
225
225
|
...(draft.harness_cli_version ? { harness_cli_version: draft.harness_cli_version } : {}),
|
|
226
226
|
...(draft.delivery_canary ? { delivery_canary: draft.delivery_canary } : {}),
|
|
227
|
+
...(draft.delivery_observations ? { delivery_observations: draft.delivery_observations } : {}),
|
|
227
228
|
skill: draft.skill,
|
|
228
229
|
harness: draft.harness,
|
|
229
230
|
model: draft.model,
|
|
@@ -498,6 +499,9 @@ export function applyOverride(results, scenarioId, override, note) {
|
|
|
498
499
|
const GITIGNORE_BODY = `# skill-harness: commit verdicts (results.yaml), ignore generated artifacts.
|
|
499
500
|
*.txt
|
|
500
501
|
*.jsonl
|
|
502
|
+
resources-*.json
|
|
503
|
+
resources-*.json.tmp
|
|
504
|
+
reuse-source-*.json
|
|
501
505
|
report.html
|
|
502
506
|
!results.yaml
|
|
503
507
|
`;
|
package/dist/run.d.ts
CHANGED
|
@@ -31,8 +31,8 @@ export interface RunOptions {
|
|
|
31
31
|
*/
|
|
32
32
|
only?: string[];
|
|
33
33
|
/**
|
|
34
|
-
* Green mode only: spend ONE probe up front
|
|
35
|
-
* and
|
|
34
|
+
* Green mode only: spend ONE instructed loading/read/quote probe up front,
|
|
35
|
+
* and stop the wave if inconclusive (see canary.ts). Off by default — it costs a
|
|
36
36
|
* rep, and the deterministic half of this failure class (a skill dir that isn't
|
|
37
37
|
* there) is already refused by the adapter for free.
|
|
38
38
|
*/
|
package/dist/run.js
CHANGED
|
@@ -19,6 +19,8 @@ import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
|
19
19
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
20
20
|
import { aggregateMetrics } from "./metrics.js";
|
|
21
21
|
import { providerFailureFromTranscript, executionFailureFromTranscript, withExecutionFailure } from "./provider-failure.js";
|
|
22
|
+
import { withResourceMonitoring } from "./resource-monitor.js";
|
|
23
|
+
import { throwIfProcessAborted, isProcessControlError } from "./util/process.js";
|
|
22
24
|
/**
|
|
23
25
|
* The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
|
|
24
26
|
* `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
|
|
@@ -54,7 +56,10 @@ export async function runSkillModel(opts) {
|
|
|
54
56
|
// --only: validate against the spec BEFORE spending anything — a typo'd id must not
|
|
55
57
|
// silently run zero scenarios and report success.
|
|
56
58
|
let scenarios = spec.scenarios;
|
|
57
|
-
const partial =
|
|
59
|
+
const partial = opts.only !== undefined;
|
|
60
|
+
if (partial && (!opts.only.length || opts.only.some(id => !id.trim() || id !== id.trim()) || new Set(opts.only).size !== opts.only.length)) {
|
|
61
|
+
throw new Error("--only requires a nonempty, unique scenario ID list");
|
|
62
|
+
}
|
|
58
63
|
if (partial) {
|
|
59
64
|
const known = new Set(spec.scenarios.map((s) => s.id));
|
|
60
65
|
const unknown = opts.only.filter((id) => !known.has(id));
|
|
@@ -78,118 +83,124 @@ export async function runSkillModel(opts) {
|
|
|
78
83
|
const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
|
|
79
84
|
mkdirSync(runDir, { recursive: true });
|
|
80
85
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
89
|
-
harness_cli_version: harnessCliVersion,
|
|
90
|
-
judge: { provider: judge.provider, model: judge.model },
|
|
91
|
-
mode, label: opts.label ?? null,
|
|
92
|
-
});
|
|
93
|
-
// The canary spends one probe before the wave, so a run that isn't measuring the
|
|
94
|
-
// skill dies for the price of a rep instead of producing a plausible scorecard.
|
|
95
|
-
// Green only: red delivers nothing by design, and force delivers through the
|
|
96
|
-
// system prompt, which needs no probe.
|
|
97
|
-
let canaryStatus = null;
|
|
98
|
-
if (opts.canary && mode !== "green") {
|
|
99
|
-
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
100
|
-
// about. Say it, and say why it isn't needed.
|
|
101
|
-
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
102
|
-
}
|
|
103
|
-
if (opts.canary && mode === "green") {
|
|
104
|
-
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
105
|
-
let canary;
|
|
106
|
-
try {
|
|
107
|
-
canary = await runDeliveryCanary({
|
|
108
|
-
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
finally {
|
|
112
|
-
probeCwd.cleanup();
|
|
113
|
-
}
|
|
86
|
+
return withResourceMonitoring(runDir, async () => {
|
|
87
|
+
throwIfProcessAborted();
|
|
88
|
+
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
89
|
+
// numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
|
|
90
|
+
// measures, and the incident was invisible in the artifacts because nothing wrote
|
|
91
|
+
// this down. Never fatal: `null` means the adapter couldn't say.
|
|
92
|
+
const harnessCliVersion = (await adapter.version?.()) ?? null;
|
|
114
93
|
appendJournal(runDir, {
|
|
115
|
-
event: "
|
|
116
|
-
|
|
94
|
+
event: "run-started", ts: now(),
|
|
95
|
+
skill: spec.skill, harness: adapter.name, model: opts.modelToken,
|
|
96
|
+
harness_cli_version: harnessCliVersion,
|
|
97
|
+
judge: { provider: judge.provider, model: judge.model },
|
|
98
|
+
mode, label: opts.label ?? null,
|
|
117
99
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
100
|
+
// One optional instructed loading probe can stop a wave whose delivery check
|
|
101
|
+
// is inconclusive. It does not measure autonomous selection in later scenarios.
|
|
102
|
+
// Green only: red delivers nothing by design, and force delivers through the
|
|
103
|
+
// system prompt, which needs no probe.
|
|
104
|
+
let canaryStatus = null;
|
|
105
|
+
let canaryObservations;
|
|
106
|
+
if (opts.canary && mode !== "green") {
|
|
107
|
+
// Ignoring a flag silently is a small version of the bug this whole feature is
|
|
108
|
+
// about. Say it, and say why it isn't needed.
|
|
109
|
+
log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
|
|
126
110
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
111
|
+
if (opts.canary && mode === "green") {
|
|
112
|
+
const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
|
|
113
|
+
let canary;
|
|
114
|
+
try {
|
|
115
|
+
canary = await runDeliveryCanary({
|
|
116
|
+
adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
probeCwd.cleanup();
|
|
121
|
+
}
|
|
122
|
+
appendJournal(runDir, {
|
|
123
|
+
event: "delivery-canary", ts: now(),
|
|
124
|
+
status: canary.status, anchor: canary.anchor, detail: canary.detail,
|
|
125
|
+
observations: canary.observations, probeCalls: canary.probeCalls,
|
|
126
|
+
});
|
|
127
|
+
canaryObservations = { ...canary.observations, probeCalls: canary.probeCalls };
|
|
128
|
+
if (canary.status === "fail")
|
|
129
|
+
throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
|
|
130
|
+
if (canary.status === "skipped") {
|
|
131
|
+
// Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
|
|
132
|
+
// requested delivery observation has to survive a commit, including a skip.
|
|
133
|
+
canaryStatus = "skipped";
|
|
134
|
+
log(` ⚠ delivery canary skipped — ${canary.detail}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
canaryStatus = "pass";
|
|
138
|
+
log(` ✓ delivery canary — ${canary.detail}`);
|
|
139
|
+
}
|
|
130
140
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// scenario × rep tasks; runPool preserves input order so we can slice per scenario.
|
|
142
|
+
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
143
|
+
const owners = [];
|
|
144
|
+
const tasks = [];
|
|
145
|
+
const armDefinitions = { count: 0 };
|
|
146
|
+
scenarios.forEach((scenario, si) => {
|
|
147
|
+
for (let k = 0; k < repCounts[si]; k++) {
|
|
148
|
+
const rep = k;
|
|
149
|
+
const total = repCounts[si];
|
|
150
|
+
owners.push(si);
|
|
151
|
+
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
155
|
+
const grouped = scenarios.map(() => []);
|
|
156
|
+
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
157
|
+
const scenarioResults = scenarios.map((scenario, si) => {
|
|
158
|
+
const threshold = scenario.critical
|
|
159
|
+
? effectiveThreshold(undefined, scenario)
|
|
160
|
+
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
161
|
+
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
162
|
+
});
|
|
163
|
+
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
164
|
+
const results = writeResults(runDir, {
|
|
165
|
+
skill: spec.skill,
|
|
166
|
+
harness: adapter.name,
|
|
167
|
+
harness_cli_version: harnessCliVersion ?? undefined,
|
|
168
|
+
delivery_canary: canaryStatus ?? undefined,
|
|
169
|
+
delivery_observations: canaryObservations,
|
|
170
|
+
model: opts.modelToken,
|
|
171
|
+
judge: { provider: judge.provider, model: judge.model },
|
|
172
|
+
timestamp,
|
|
173
|
+
label: opts.label ?? null,
|
|
174
|
+
mode,
|
|
175
|
+
...(partial ? { partial: true } : {}),
|
|
176
|
+
// Only the scenarios this run actually measured: a --only run must not claim
|
|
177
|
+
// coverage of scenarios it skipped.
|
|
178
|
+
source_hashes: hashes,
|
|
179
|
+
...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
|
|
180
|
+
scenarios: scenarioResults,
|
|
181
|
+
...(arm.name === NONE_ARM.name ? {} : {
|
|
182
|
+
arm: {
|
|
183
|
+
name: arm.name,
|
|
184
|
+
extensions: arm.extensions,
|
|
185
|
+
definitions: armDefinitions.count,
|
|
186
|
+
ledger_events: countLedgerEvents(runDir),
|
|
187
|
+
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
188
|
+
// being measured (grant, max depth), so leaving it out made two runs at
|
|
189
|
+
// different settings byte-identical here and inside the same `+<arm>`
|
|
190
|
+
// tag — `stability` then reads the verdict difference between two
|
|
191
|
+
// conditions as one lineage flipping. The substituted form would be the
|
|
192
|
+
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
193
|
+
// condition would record two different-looking arms.
|
|
194
|
+
env: arm.env,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
}, ctx);
|
|
198
|
+
if (ctx) {
|
|
199
|
+
const g = results.effective_grade;
|
|
200
|
+
appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
|
|
143
201
|
}
|
|
202
|
+
return { runDir, results };
|
|
144
203
|
});
|
|
145
|
-
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
146
|
-
const grouped = scenarios.map(() => []);
|
|
147
|
-
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
148
|
-
const scenarioResults = scenarios.map((scenario, si) => {
|
|
149
|
-
const threshold = scenario.critical
|
|
150
|
-
? effectiveThreshold(undefined, scenario)
|
|
151
|
-
: scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
152
|
-
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
153
|
-
});
|
|
154
|
-
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
155
|
-
const results = writeResults(runDir, {
|
|
156
|
-
skill: spec.skill,
|
|
157
|
-
harness: adapter.name,
|
|
158
|
-
harness_cli_version: harnessCliVersion ?? undefined,
|
|
159
|
-
delivery_canary: canaryStatus ?? undefined,
|
|
160
|
-
model: opts.modelToken,
|
|
161
|
-
judge: { provider: judge.provider, model: judge.model },
|
|
162
|
-
timestamp,
|
|
163
|
-
label: opts.label ?? null,
|
|
164
|
-
mode,
|
|
165
|
-
...(partial ? { partial: true } : {}),
|
|
166
|
-
// Only the scenarios this run actually measured: a --only run must not claim
|
|
167
|
-
// coverage of scenarios it skipped.
|
|
168
|
-
source_hashes: hashes,
|
|
169
|
-
...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
|
|
170
|
-
scenarios: scenarioResults,
|
|
171
|
-
...(arm.name === NONE_ARM.name ? {} : {
|
|
172
|
-
arm: {
|
|
173
|
-
name: arm.name,
|
|
174
|
-
extensions: arm.extensions,
|
|
175
|
-
definitions: armDefinitions.count,
|
|
176
|
-
ledger_events: countLedgerEvents(runDir),
|
|
177
|
-
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
178
|
-
// being measured (grant, max depth), so leaving it out made two runs at
|
|
179
|
-
// different settings byte-identical here and inside the same `+<arm>`
|
|
180
|
-
// tag — `stability` then reads the verdict difference between two
|
|
181
|
-
// conditions as one lineage flipping. The substituted form would be the
|
|
182
|
-
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
183
|
-
// condition would record two different-looking arms.
|
|
184
|
-
env: arm.env,
|
|
185
|
-
},
|
|
186
|
-
}),
|
|
187
|
-
}, ctx);
|
|
188
|
-
if (ctx) {
|
|
189
|
-
const g = results.effective_grade;
|
|
190
|
-
appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
|
|
191
|
-
}
|
|
192
|
-
return { runDir, results };
|
|
193
204
|
}
|
|
194
205
|
/**
|
|
195
206
|
* True when any assistant turn in a transcript is blank — the shape a harness timeout
|
|
@@ -288,6 +299,7 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
288
299
|
// it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
|
|
289
300
|
// An adapter that THREW is the same class of event and takes the same retry.
|
|
290
301
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
302
|
+
throwIfProcessAborted();
|
|
291
303
|
if (attempt > 0) {
|
|
292
304
|
const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
|
|
293
305
|
appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
|
|
@@ -368,6 +380,9 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
368
380
|
}
|
|
369
381
|
}
|
|
370
382
|
catch (e) {
|
|
383
|
+
if (isProcessControlError(e))
|
|
384
|
+
throw e;
|
|
385
|
+
throwIfProcessAborted();
|
|
371
386
|
// An adapter that THREW is one rep's infrastructure failure, not the
|
|
372
387
|
// wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
|
|
373
388
|
// a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
|
|
@@ -643,7 +658,12 @@ export function formatScorecard(summary, lift, stability) {
|
|
|
643
658
|
lines.push(` NOTE: green delivery is harness-version-dependent` +
|
|
644
659
|
(results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
|
|
645
660
|
` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
|
|
646
|
-
` Use --mode force for
|
|
661
|
+
` Use --mode force for explicit instruction delivery, or --canary for one instructed loading check; autonomous selection remains untested.`);
|
|
662
|
+
}
|
|
663
|
+
if (results.mode === "green" && results.delivery_canary === "pass") {
|
|
664
|
+
const observation = results.delivery_observations;
|
|
665
|
+
lines.push(` DELIVERY: instructed probe read ${observation?.successfulSkillRead ?? "unobserved"}; ` +
|
|
666
|
+
`autonomous skill selection in scenarios was not measured by the probe.`);
|
|
647
667
|
}
|
|
648
668
|
// Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
|
|
649
669
|
// flipped between the last two runs is one draw, whatever its rep count said.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Spec } from "./spec.js";
|
|
2
|
+
export interface SavedAnswerPlan {
|
|
3
|
+
providerCalls: 0;
|
|
4
|
+
resultsChanged: false;
|
|
5
|
+
scenarios: {
|
|
6
|
+
id: string;
|
|
7
|
+
action: "reuse" | "regrade" | "regate" | "rescore" | "rerun" | "inspect";
|
|
8
|
+
changed: string[];
|
|
9
|
+
note: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
/** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
|
|
13
|
+
export declare function planSavedAnswers(runDir: string, spec: Spec, skillDir: string, specDir: string): SavedAnswerPlan;
|
|
14
|
+
/** Copy a selected run, with limits and byte identities, before revising its judgments. */
|
|
15
|
+
export declare function forkSavedRun(source: string, destination: string): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { readResults, findTranscriptFiles, transcriptPath } from "./results.js";
|
|
5
|
+
import { currentHashFor, isSupersededKey, sourceHashes } from "./sources.js";
|
|
6
|
+
/** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
|
|
7
|
+
export function planSavedAnswers(runDir, spec, skillDir, specDir) {
|
|
8
|
+
const previous = readResults(runDir);
|
|
9
|
+
const context = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
|
|
10
|
+
const recorded = previous.source_hashes;
|
|
11
|
+
const current = sourceHashes(context);
|
|
12
|
+
const changed = Object.keys({ ...recorded, ...current }).filter(key => {
|
|
13
|
+
if (isSupersededKey(key, { ...recorded, ...current }))
|
|
14
|
+
return false;
|
|
15
|
+
return !recorded || !(key in recorded) || currentHashFor(key, context) !== recorded[key];
|
|
16
|
+
});
|
|
17
|
+
return { providerCalls: 0, resultsChanged: false, scenarios: spec.scenarios.map(s => {
|
|
18
|
+
const prior = previous.scenarios.find(p => p.id === s.id);
|
|
19
|
+
const relevant = changed.filter(key => {
|
|
20
|
+
if (/^(stimulus|rubric|policy|gates|scenario):/.test(key) && key !== "rubric:__persona")
|
|
21
|
+
return key.slice(key.indexOf(":") + 1) === s.id;
|
|
22
|
+
if (key.startsWith("fixture:"))
|
|
23
|
+
return key.slice(8) === (typeof s.workspace === "object" ? s.workspace.fixture : s.fixture);
|
|
24
|
+
if (key.startsWith("prompt:"))
|
|
25
|
+
return key.slice(7) === s.systemPromptFile;
|
|
26
|
+
if (!key.includes(":") && key !== "SKILL.md")
|
|
27
|
+
return [s.systemPromptFile, s.assert?.post_test, ...(s.extensions ?? [])].includes(key);
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
const base = { id: s.id, changed: relevant };
|
|
31
|
+
if (!prior)
|
|
32
|
+
return { ...base, action: "rerun", note: "No saved answer exists for this scenario." };
|
|
33
|
+
const files = findTranscriptFiles(runDir, s.id, previous.mode);
|
|
34
|
+
const reps = prior.reps ?? 1;
|
|
35
|
+
const complete = Number.isSafeInteger(reps) && reps > 0 && files.length === reps && files.every((file, rep) => {
|
|
36
|
+
if (file !== basename(transcriptPath(runDir, s.id, previous.mode, reps === 1 ? undefined : rep)))
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
const stat = lstatSync(join(runDir, file));
|
|
40
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.size > 0;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (!complete)
|
|
47
|
+
return { ...base, action: "inspect", note: "Saved answers are incomplete; recover original evidence before planning paid work." };
|
|
48
|
+
if (!recorded || !recorded[`stimulus:${s.id}`])
|
|
49
|
+
return { ...base, action: "inspect", note: "Input identity is incomplete; do not assume a saved answer matches this task." };
|
|
50
|
+
if (relevant.some(key => key in recorded && currentHashFor(key, context) === undefined))
|
|
51
|
+
return { ...base, action: "inspect", note: "An input identity cannot be interpreted by this harness; inspect the original evidence before planning a rerun." };
|
|
52
|
+
if (prior.judge_verdict === "ERROR")
|
|
53
|
+
return { ...base, action: "inspect", note: "Resolve transport/capture errors first; recover saved answers before considering another subject run." };
|
|
54
|
+
const stimulusChanged = relevant.some(key => !/^(rubric|gates|policy):/.test(key));
|
|
55
|
+
if (stimulusChanged)
|
|
56
|
+
return { ...base, action: "rerun", note: "Subject inputs or skill changed. Old answers remain historical and cannot establish current behavior." };
|
|
57
|
+
if (relevant.some(k => k.startsWith("gates:")))
|
|
58
|
+
return { ...base, action: "regate", note: "Replay supported assertions on saved evidence; missing execution evidence remains unknown. Regrade too if the rubric changed." };
|
|
59
|
+
if (relevant.some(k => k.startsWith("rubric:")))
|
|
60
|
+
return { ...base, action: "regrade", note: "Judge the saved answers against corrected references/rubric; no subject call is needed." };
|
|
61
|
+
if (relevant.some(k => k.startsWith("policy:")))
|
|
62
|
+
return { ...base, action: "rescore", note: "Recompute policy using retained verdicts; no provider calls." };
|
|
63
|
+
return { ...base, action: "reuse", note: "Inputs match. Retain the existing verdict, including failures; this is not a new stability sample." };
|
|
64
|
+
}) };
|
|
65
|
+
}
|
|
66
|
+
/** Copy a selected run, with limits and byte identities, before revising its judgments. */
|
|
67
|
+
export function forkSavedRun(source, destination) {
|
|
68
|
+
const root = realpathSync(source), out = join(realpathSync(dirname(resolve(destination))), basename(destination));
|
|
69
|
+
const rel = relative(root, out);
|
|
70
|
+
if (!rel || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\")))
|
|
71
|
+
throw new Error("Saved-run output must be outside the original run");
|
|
72
|
+
const files = [];
|
|
73
|
+
let bytes = 0, entries = 0;
|
|
74
|
+
const sha = (data) => createHash("sha256").update(data).digest("hex");
|
|
75
|
+
const visit = (dir, prefix, depth) => {
|
|
76
|
+
if (depth > 16)
|
|
77
|
+
throw new Error("Saved-run copy exceeds directory depth limit");
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (++entries > 4096)
|
|
80
|
+
throw new Error("Saved-run copy exceeds 4096 entries");
|
|
81
|
+
const path = join(dir, entry.name), key = prefix + entry.name, stat = lstatSync(path);
|
|
82
|
+
if (stat.isSymbolicLink())
|
|
83
|
+
throw new Error("Saved-run copy refuses symbolic links");
|
|
84
|
+
if (stat.isDirectory())
|
|
85
|
+
visit(path, key + "/", depth + 1);
|
|
86
|
+
else if (stat.isFile()) {
|
|
87
|
+
bytes += stat.size;
|
|
88
|
+
if (stat.size > 64 * 1024 * 1024 || bytes > 256 * 1024 * 1024)
|
|
89
|
+
throw new Error("Saved-run copy exceeds evidence byte limit; select a smaller run");
|
|
90
|
+
files.push({ path: key, bytes: stat.size, sha256: sha(readFileSync(path)) });
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
throw new Error("Saved-run copy requires regular files");
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
visit(root, "", 0);
|
|
97
|
+
if (!files.some(f => f.path === "results.yaml"))
|
|
98
|
+
throw new Error("Saved run lacks results.yaml");
|
|
99
|
+
mkdirSync(out, { mode: 0o700 }); // Exclusive: never overwrite another result directory.
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const data = readFileSync(join(root, file.path));
|
|
102
|
+
if (sha(data) !== file.sha256)
|
|
103
|
+
throw new Error("Saved evidence changed during copy; output is incomplete");
|
|
104
|
+
const target = join(out, file.path);
|
|
105
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(target, data, { flag: "wx", mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
for (const file of files)
|
|
109
|
+
if (sha(readFileSync(join(root, file.path))) !== file.sha256 || sha(readFileSync(join(out, file.path))) !== file.sha256)
|
|
110
|
+
throw new Error("Saved evidence verification failed; output is incomplete");
|
|
111
|
+
writeFileSync(join(out, `reuse-source-${Date.now()}.json`), JSON.stringify({ schema: 1, purpose: "saved-answer-regrade", source: root, files, subjectCalls: 0 }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=saved-run.js.map
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
* Run `tasks` with at most `concurrency` thunks in flight at once, returning
|
|
3
3
|
* their results in input order (not completion order). `concurrency <= 1` runs
|
|
4
4
|
* them strictly sequentially — identical to a plain for-await loop. A thunk that
|
|
5
|
-
* throws
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* further tasks before the rejection unwinds; the guarantee is that runPool
|
|
9
|
-
* rejects, not that dispatch halts.
|
|
5
|
+
* throws stops new dispatch; already claimed tasks settle before rejection.
|
|
6
|
+
* A surrounding process scope supplies cancellation to active subprocesses.
|
|
7
|
+
* Waiting for settlement prevents reporting a stopped run while its workers remain active.
|
|
10
8
|
*/
|
|
11
9
|
export declare function runPool<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]>;
|
package/dist/scheduler.js
CHANGED
|
@@ -2,26 +2,38 @@
|
|
|
2
2
|
* Run `tasks` with at most `concurrency` thunks in flight at once, returning
|
|
3
3
|
* their results in input order (not completion order). `concurrency <= 1` runs
|
|
4
4
|
* them strictly sequentially — identical to a plain for-await loop. A thunk that
|
|
5
|
-
* throws
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* further tasks before the rejection unwinds; the guarantee is that runPool
|
|
9
|
-
* rejects, not that dispatch halts.
|
|
5
|
+
* throws stops new dispatch; already claimed tasks settle before rejection.
|
|
6
|
+
* A surrounding process scope supplies cancellation to active subprocesses.
|
|
7
|
+
* Waiting for settlement prevents reporting a stopped run while its workers remain active.
|
|
10
8
|
*/
|
|
11
9
|
export async function runPool(tasks, concurrency) {
|
|
12
10
|
const limit = Math.max(1, Math.floor(concurrency));
|
|
13
11
|
const results = new Array(tasks.length);
|
|
14
12
|
let next = 0;
|
|
13
|
+
let failure;
|
|
14
|
+
let failed = false;
|
|
15
15
|
async function worker() {
|
|
16
16
|
while (true) {
|
|
17
|
+
if (failed)
|
|
18
|
+
return;
|
|
17
19
|
const i = next++;
|
|
18
20
|
if (i >= tasks.length)
|
|
19
21
|
return;
|
|
20
|
-
|
|
22
|
+
try {
|
|
23
|
+
results[i] = await tasks[i]();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!failed)
|
|
27
|
+
failure = error;
|
|
28
|
+
failed = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
const workerCount = Math.min(limit, tasks.length);
|
|
24
34
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
35
|
+
if (failed)
|
|
36
|
+
throw failure;
|
|
25
37
|
return results;
|
|
26
38
|
}
|
|
27
39
|
//# sourceMappingURL=scheduler.js.map
|