taskchef 5.1.0 → 5.2.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/.codex-plugin/plugin.json +1 -1
- package/README.md +11 -0
- package/SPEC.md +58 -9
- package/assets/e2e-benchmark-example.json +46 -0
- package/package.json +3 -1
- package/scripts/e2e-benchmark.js +517 -0
- package/skills/taskchef-delegate/SKILL.md +33 -4
- package/src/delegation.js +37 -32
package/README.md
CHANGED
|
@@ -241,6 +241,17 @@ loads and validates configured projects, and returns a generated task UUID,
|
|
|
241
241
|
preparation timestamp, and exact correlation marker. `task record` accepts one
|
|
242
242
|
JSON value only from closed, non-interactive standard input.
|
|
243
243
|
|
|
244
|
+
For repeatable live delegation measurements, run
|
|
245
|
+
`npm run benchmark:e2e -- write`. It reads one non-interactive JSON value,
|
|
246
|
+
validates a stable schema, derives durations and summary totals, and writes a
|
|
247
|
+
timestamped result under the supplied output directory (default:
|
|
248
|
+
`reports/e2e-benchmarks`). Use `validate <file>` to verify a saved result and
|
|
249
|
+
`clean [directory]` to remove only prior TaskChef end-to-end result JSON files.
|
|
250
|
+
Start from `assets/e2e-benchmark-example.json`; the writer accepts strict
|
|
251
|
+
schema fields only and supports stopped workflows after preparation, creation,
|
|
252
|
+
or recording failures. The writer stamps `taskchefVersion` from its own package;
|
|
253
|
+
saved files retain that version for historical validation.
|
|
254
|
+
|
|
244
255
|
### One-time upgrade from an older workspace
|
|
245
256
|
|
|
246
257
|
TaskChef 5 does not include a general migration command. For a one-time upgrade,
|
package/SPEC.md
CHANGED
|
@@ -196,10 +196,12 @@ For each assignment, `$taskchef-delegate`:
|
|
|
196
196
|
marked entry with `threadId: null`, then prefers one native client-ID wait or
|
|
197
197
|
resolution call with a 30-second timeout when Codex exposes one
|
|
198
198
|
7. when no native operation is available, takes at most two recent-thread
|
|
199
|
-
snapshots
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
snapshots on a nominal 10- and 30-second schedule after the provisional
|
|
200
|
+
result; a late first checkpoint runs immediately, and the second starts at
|
|
201
|
+
least 20 seconds after the first actually started. It filters candidates by
|
|
202
|
+
available host/project/time/worktree metadata, uses title only as an advisory
|
|
203
|
+
ordering hint, and accepts only one thread whose structured delegated input
|
|
204
|
+
starts with the exact marker
|
|
203
205
|
8. atomically fills the nullable thread ID after an exact match
|
|
204
206
|
9. returns after recording or after reporting that bounded resolution was
|
|
205
207
|
unresolved, without waiting for executor work completion.
|
|
@@ -209,11 +211,13 @@ Creation-time filtering allows five seconds of clock skew. Candidate reads run
|
|
|
209
211
|
concurrently where the host permits and inspect only structured
|
|
210
212
|
`codexDelegation.input`, never untrusted title, summary, preview, or plain-text
|
|
211
213
|
marker echoes. A native resolver result is verified against the same structured
|
|
212
|
-
marker before persistence. Zero exact matches
|
|
213
|
-
|
|
214
|
-
task-resolution errors leave the already-recorded nullable entry intact.
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
marker before persistence. Zero exact matches remain unresolved; multiple exact
|
|
215
|
+
matches are ambiguous. Snapshot, candidate-read, native-resolution, or
|
|
216
|
+
task-resolution errors leave the already-recorded nullable entry intact. The
|
|
217
|
+
fallback is bounded by two snapshots rather than an absolute wall-clock cutoff:
|
|
218
|
+
mandatory recording or tool latency can shift both attempts later, but cannot
|
|
219
|
+
erase an attempt or reduce the minimum 20-second interval between their start
|
|
220
|
+
times.
|
|
217
221
|
A `clientThreadId`, `pendingWorktreeId`, or ID in the documented provisional
|
|
218
222
|
`local:` namespace remains diagnostic context and is rejected from every path
|
|
219
223
|
that could persist the canonical `threadId` field.
|
|
@@ -224,6 +228,51 @@ helpers with injected thread-tool callbacks, while the skill owns the actual
|
|
|
224
228
|
desktop-tool calls and the CLI remains responsible only for validated data
|
|
225
229
|
operations.
|
|
226
230
|
|
|
231
|
+
### End-to-end benchmark results
|
|
232
|
+
|
|
233
|
+
An explicitly requested live benchmark writes one ignored, timestamped JSON
|
|
234
|
+
artifact through `scripts/e2e-benchmark.js`. Schema version 1 contains:
|
|
235
|
+
|
|
236
|
+
- benchmark name, TaskChef version, run ID, and wall-clock start/end
|
|
237
|
+
- project, title, and exact unmarked workload prompt
|
|
238
|
+
- TaskChef task ID, nullable durable/client IDs, recording state, resolution,
|
|
239
|
+
and attempt count
|
|
240
|
+
- ISO start/end, derived duration, and outcome for preparation/project listing,
|
|
241
|
+
task creation, recording, and optional provisional resolution
|
|
242
|
+
- boolean record, marker, executor-output, and candidate-filter-effectiveness
|
|
243
|
+
validation results
|
|
244
|
+
- derived total wall time, measured stage time, orchestration overhead,
|
|
245
|
+
resolution state, and attempt count
|
|
246
|
+
|
|
247
|
+
Stage outcomes are `success|failed` for preparation,
|
|
248
|
+
`durable|provisional|failed` for creation, `recorded|failed` for recording, and
|
|
249
|
+
`native|discovered|unresolved|failed` for provisional resolution. `failed`
|
|
250
|
+
distinguishes a terminal resolver error from a completed no-match checkpoint,
|
|
251
|
+
which must proceed to the second fallback attempt. Task resolution is
|
|
252
|
+
`immediate|native|discovered|unresolved`. Preparation failure has only the
|
|
253
|
+
preparation stage and a null task ID; creation failure stops after creation;
|
|
254
|
+
recording failure stops before provisional resolution. The committed example
|
|
255
|
+
shows the durable success shape, while these transition rules define the
|
|
256
|
+
shorter failure and longer provisional shapes.
|
|
257
|
+
The writer derives `taskchefVersion` from the running package instead of
|
|
258
|
+
trusting template input. Raw executor output and transcripts are never stored;
|
|
259
|
+
`outputVerified` records only whether the requested output was verified.
|
|
260
|
+
|
|
261
|
+
The writer rejects missing required stages, duplicate or unknown stages,
|
|
262
|
+
invalid timestamps, contradictory workflow outcomes, overlapping stages,
|
|
263
|
+
unknown fields, and overwriting an existing run. Later stages are omitted when
|
|
264
|
+
preparation or creation fails, and provisional resolution is omitted when task
|
|
265
|
+
recording fails. Fallback snapshot observations include the complete recent
|
|
266
|
+
task count plus the filtered candidate and exact-match counts, from which
|
|
267
|
+
candidate-filter effectiveness is checked. Each snapshot records
|
|
268
|
+
`resolveWriteOutcome` as `not-attempted`, `succeeded`, or `failed`, so a unique
|
|
269
|
+
match followed by an atomic write failure remains an honest unresolved result.
|
|
270
|
+
A compact valid starting document is committed as
|
|
271
|
+
`assets/e2e-benchmark-example.json`. Filenames use
|
|
272
|
+
`<startedAt-with-colons-replaced>-taskchef-delegate-e2e.json`. Its cleanup
|
|
273
|
+
operation removes only files matching the full generated filename grammar in
|
|
274
|
+
the selected results directory.
|
|
275
|
+
|
|
227
276
|
`task record` rejects an interactive TTY before reading because its protocol is
|
|
228
277
|
exactly one JSON value followed by EOF. Workspace-lock contention is retried for
|
|
229
278
|
up to seven seconds, while permanent permission failures such as `EPERM` or
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"benchmark": "taskchef-delegate-e2e",
|
|
4
|
+
"runId": "replace-with-run-id",
|
|
5
|
+
"startedAt": "2026-08-13T06:30:00.000Z",
|
|
6
|
+
"completedAt": "2026-08-13T06:30:05.000Z",
|
|
7
|
+
"workload": {
|
|
8
|
+
"project": "example-project",
|
|
9
|
+
"title": "Example workload",
|
|
10
|
+
"prompt": "Return the requested example output."
|
|
11
|
+
},
|
|
12
|
+
"task": {
|
|
13
|
+
"taskId": "00000000-0000-4000-8000-000000000000",
|
|
14
|
+
"threadId": "00000000-0000-7000-8000-000000000000",
|
|
15
|
+
"clientThreadId": null,
|
|
16
|
+
"recorded": true,
|
|
17
|
+
"resolution": "immediate",
|
|
18
|
+
"resolutionAttempts": 0
|
|
19
|
+
},
|
|
20
|
+
"stages": [
|
|
21
|
+
{
|
|
22
|
+
"name": "prepare-and-list-projects",
|
|
23
|
+
"startedAt": "2026-08-13T06:30:00.000Z",
|
|
24
|
+
"completedAt": "2026-08-13T06:30:00.200Z",
|
|
25
|
+
"outcome": "success"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "create-thread",
|
|
29
|
+
"startedAt": "2026-08-13T06:30:01.000Z",
|
|
30
|
+
"completedAt": "2026-08-13T06:30:01.100Z",
|
|
31
|
+
"outcome": "durable"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "record-task",
|
|
35
|
+
"startedAt": "2026-08-13T06:30:02.000Z",
|
|
36
|
+
"completedAt": "2026-08-13T06:30:04.000Z",
|
|
37
|
+
"outcome": "recorded"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"validation": {
|
|
41
|
+
"recordVerified": true,
|
|
42
|
+
"markerVerified": true,
|
|
43
|
+
"outputVerified": false,
|
|
44
|
+
"candidateFilterEffective": false
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"index.js",
|
|
26
26
|
"SPEC.md",
|
|
27
27
|
"scripts/benchmark-dispatch-prepare.js",
|
|
28
|
+
"scripts/e2e-benchmark.js",
|
|
28
29
|
"src",
|
|
29
30
|
"skills/taskchef-bootstrap",
|
|
30
31
|
"skills/taskchef-delegate",
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
42
|
"benchmark:dispatch": "node scripts/benchmark-dispatch-prepare.js",
|
|
43
|
+
"benchmark:e2e": "node scripts/e2e-benchmark.js",
|
|
42
44
|
"test": "node --test tests/taskchef.test.js"
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const BENCHMARK_NAME = "taskchef-delegate-e2e";
|
|
8
|
+
const STAGE_NAMES = [
|
|
9
|
+
"prepare-and-list-projects",
|
|
10
|
+
"create-thread",
|
|
11
|
+
"record-task",
|
|
12
|
+
"resolve-provisional",
|
|
13
|
+
];
|
|
14
|
+
const STAGE_OUTCOMES = Object.freeze({
|
|
15
|
+
"prepare-and-list-projects": new Set(["success", "failed"]),
|
|
16
|
+
"create-thread": new Set(["durable", "provisional", "failed"]),
|
|
17
|
+
"record-task": new Set(["recorded", "failed"]),
|
|
18
|
+
"resolve-provisional": new Set(["native", "discovered", "unresolved", "failed"]),
|
|
19
|
+
});
|
|
20
|
+
const RESOLUTIONS = new Set(["immediate", "native", "discovered", "unresolved"]);
|
|
21
|
+
const RESULT_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-taskchef-delegate-e2e\.json$/;
|
|
22
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
23
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
24
|
+
|
|
25
|
+
function requireExactKeys(value, allowed, name) {
|
|
26
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
27
|
+
if (unknown.length > 0) throw new Error(`${name} has unknown field ${unknown[0]}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireObject(value, name) {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
32
|
+
throw new Error(`${name} must be an object`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requireString(value, name) {
|
|
38
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
39
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timestamp(value, name) {
|
|
45
|
+
requireString(value, name);
|
|
46
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
|
47
|
+
throw new Error(`${name} must use a four-digit UTC year`);
|
|
48
|
+
}
|
|
49
|
+
const milliseconds = Date.parse(value);
|
|
50
|
+
if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
|
|
51
|
+
throw new Error(`${name} must be a canonical UTC ISO timestamp`);
|
|
52
|
+
}
|
|
53
|
+
return milliseconds;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nullableString(value, name) {
|
|
57
|
+
if (value === null) return null;
|
|
58
|
+
return requireString(value, name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function managedResultFilename(name) {
|
|
62
|
+
if (!RESULT_FILENAME_PATTERN.test(name)) return false;
|
|
63
|
+
const timestampPart = name.slice(0, -`-${BENCHMARK_NAME}.json`.length);
|
|
64
|
+
const iso = `${timestampPart.slice(0, 13)}:${timestampPart.slice(14, 16)}:${timestampPart.slice(17)}`;
|
|
65
|
+
try {
|
|
66
|
+
timestamp(iso, "result filename timestamp");
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireNonnegativeNumber(value, name) {
|
|
74
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
75
|
+
throw new Error(`${name} must be a nonnegative number`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeObservations(
|
|
81
|
+
input,
|
|
82
|
+
startedMs,
|
|
83
|
+
completedMs,
|
|
84
|
+
preparationStage,
|
|
85
|
+
creationStage,
|
|
86
|
+
recordingStage,
|
|
87
|
+
resolutionStage,
|
|
88
|
+
resolutionAttempts,
|
|
89
|
+
) {
|
|
90
|
+
if (input === undefined) return undefined;
|
|
91
|
+
const observations = requireObject(input, "observations");
|
|
92
|
+
requireExactKeys(observations, [
|
|
93
|
+
"preparation",
|
|
94
|
+
"resolutionSnapshots",
|
|
95
|
+
"validationDurationMs",
|
|
96
|
+
"candidateMetadata",
|
|
97
|
+
], "observations");
|
|
98
|
+
const result = {};
|
|
99
|
+
if (observations.preparation !== undefined) {
|
|
100
|
+
const preparation = requireObject(observations.preparation, "observations.preparation");
|
|
101
|
+
requireExactKeys(preparation, ["dispatchPrepareMs", "nativeProjectListMs"], "observations.preparation");
|
|
102
|
+
result.preparation = {
|
|
103
|
+
dispatchPrepareMs: requireNonnegativeNumber(preparation.dispatchPrepareMs, "observations.preparation.dispatchPrepareMs"),
|
|
104
|
+
nativeProjectListMs: requireNonnegativeNumber(preparation.nativeProjectListMs, "observations.preparation.nativeProjectListMs"),
|
|
105
|
+
};
|
|
106
|
+
const preparationDurationMs = timestamp(preparationStage.completedAt, "prepare-and-list-projects.completedAt") -
|
|
107
|
+
timestamp(preparationStage.startedAt, "prepare-and-list-projects.startedAt");
|
|
108
|
+
if (Object.values(result.preparation).some((durationMs) => durationMs > preparationDurationMs)) {
|
|
109
|
+
throw new Error("observations.preparation durations must fit within the preparation stage");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (observations.resolutionSnapshots !== undefined) {
|
|
113
|
+
if (!Array.isArray(observations.resolutionSnapshots)) {
|
|
114
|
+
throw new Error("observations.resolutionSnapshots must be an array");
|
|
115
|
+
}
|
|
116
|
+
let previousSnapshotStartedMs = null;
|
|
117
|
+
let previousSnapshotCompletedMs = null;
|
|
118
|
+
result.resolutionSnapshots = observations.resolutionSnapshots.map((snapshot, index) => {
|
|
119
|
+
requireObject(snapshot, `observations.resolutionSnapshots[${index}]`);
|
|
120
|
+
requireExactKeys(snapshot, [
|
|
121
|
+
"attempt", "startedAt", "completedAt", "recentTaskCount", "candidateCount", "exactMatchCount",
|
|
122
|
+
"resolveWriteMs", "resolveWriteOutcome",
|
|
123
|
+
], `observations.resolutionSnapshots[${index}]`);
|
|
124
|
+
if (!Number.isInteger(snapshot.attempt) || snapshot.attempt !== index + 1 || snapshot.attempt > resolutionAttempts) {
|
|
125
|
+
throw new Error(`observations.resolutionSnapshots[${index}].attempt must be sequential and within resolutionAttempts`);
|
|
126
|
+
}
|
|
127
|
+
const snapshotStartedMs = timestamp(snapshot.startedAt, `observations.resolutionSnapshots[${index}].startedAt`);
|
|
128
|
+
const snapshotCompletedMs = timestamp(snapshot.completedAt, `observations.resolutionSnapshots[${index}].completedAt`);
|
|
129
|
+
const resolutionStartedMs = resolutionStage ? timestamp(resolutionStage.startedAt, "resolve-provisional.startedAt") : null;
|
|
130
|
+
const resolutionCompletedMs = resolutionStage ? timestamp(resolutionStage.completedAt, "resolve-provisional.completedAt") : null;
|
|
131
|
+
if (
|
|
132
|
+
resolutionStartedMs === null || snapshotStartedMs < resolutionStartedMs ||
|
|
133
|
+
snapshotCompletedMs < snapshotStartedMs || snapshotCompletedMs > resolutionCompletedMs ||
|
|
134
|
+
snapshotStartedMs < startedMs || snapshotCompletedMs > completedMs
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(`observations.resolutionSnapshots[${index}] must fall within the resolution stage`);
|
|
137
|
+
}
|
|
138
|
+
const firstCheckpointMs = Math.max(
|
|
139
|
+
timestamp(creationStage.completedAt, "create-thread.completedAt") + 10_000,
|
|
140
|
+
timestamp(recordingStage.completedAt, "record-task.completedAt"),
|
|
141
|
+
);
|
|
142
|
+
if (index === 0 && snapshotStartedMs < firstCheckpointMs) {
|
|
143
|
+
throw new Error("the first resolution snapshot must respect the 10-second catch-up checkpoint");
|
|
144
|
+
}
|
|
145
|
+
if (previousSnapshotCompletedMs !== null && snapshotStartedMs < previousSnapshotCompletedMs) {
|
|
146
|
+
throw new Error("resolutionSnapshots must be ordered and non-overlapping");
|
|
147
|
+
}
|
|
148
|
+
if (previousSnapshotStartedMs !== null && snapshotStartedMs - previousSnapshotStartedMs < 20_000) {
|
|
149
|
+
throw new Error("resolutionSnapshots must start at least 20 seconds apart");
|
|
150
|
+
}
|
|
151
|
+
for (const name of ["recentTaskCount", "candidateCount", "exactMatchCount", "resolveWriteMs"]) {
|
|
152
|
+
requireNonnegativeNumber(snapshot[name], `observations.resolutionSnapshots[${index}].${name}`);
|
|
153
|
+
}
|
|
154
|
+
if (!["not-attempted", "succeeded", "failed"].includes(snapshot.resolveWriteOutcome)) {
|
|
155
|
+
throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteOutcome is invalid`);
|
|
156
|
+
}
|
|
157
|
+
if ((snapshot.exactMatchCount === 1) !== (snapshot.resolveWriteOutcome !== "not-attempted")) {
|
|
158
|
+
throw new Error(`observations.resolutionSnapshots[${index}] resolve write must agree with exact matches`);
|
|
159
|
+
}
|
|
160
|
+
if (snapshot.resolveWriteOutcome === "not-attempted" && snapshot.resolveWriteMs !== 0) {
|
|
161
|
+
throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteMs must be zero when no write was attempted`);
|
|
162
|
+
}
|
|
163
|
+
if (!Number.isInteger(snapshot.recentTaskCount) || !Number.isInteger(snapshot.candidateCount) || !Number.isInteger(snapshot.exactMatchCount)) {
|
|
164
|
+
throw new Error(`observations.resolutionSnapshots[${index}] counts must be integers`);
|
|
165
|
+
}
|
|
166
|
+
if (snapshot.recentTaskCount > 50) {
|
|
167
|
+
throw new Error(`observations.resolutionSnapshots[${index}].recentTaskCount must not exceed 50`);
|
|
168
|
+
}
|
|
169
|
+
if (snapshot.candidateCount > snapshot.recentTaskCount || snapshot.exactMatchCount > snapshot.candidateCount) {
|
|
170
|
+
throw new Error(`observations.resolutionSnapshots[${index}] counts are inconsistent`);
|
|
171
|
+
}
|
|
172
|
+
if (snapshot.resolveWriteMs > snapshotCompletedMs - snapshotStartedMs) {
|
|
173
|
+
throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteMs exceeds snapshot duration`);
|
|
174
|
+
}
|
|
175
|
+
previousSnapshotStartedMs = snapshotStartedMs;
|
|
176
|
+
previousSnapshotCompletedMs = snapshotCompletedMs;
|
|
177
|
+
return { ...snapshot };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (observations.validationDurationMs !== undefined) {
|
|
181
|
+
result.validationDurationMs = requireNonnegativeNumber(observations.validationDurationMs, "observations.validationDurationMs");
|
|
182
|
+
const finalStage = resolutionStage ?? recordingStage ?? creationStage ?? preparationStage;
|
|
183
|
+
const postWorkflowMs = completedMs - timestamp(finalStage.completedAt, `${finalStage.name}.completedAt`);
|
|
184
|
+
if (result.validationDurationMs > postWorkflowMs) {
|
|
185
|
+
throw new Error("observations.validationDurationMs must fit after the workflow stages");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (observations.candidateMetadata !== undefined) {
|
|
189
|
+
const metadata = requireObject(observations.candidateMetadata, "observations.candidateMetadata");
|
|
190
|
+
const names = ["targetProjectIdPresent", "targetCreatedAtPresent", "targetEnvironmentPresent", "targetCwdPresent"];
|
|
191
|
+
requireExactKeys(metadata, names, "observations.candidateMetadata");
|
|
192
|
+
for (const name of names) {
|
|
193
|
+
if (typeof metadata[name] !== "boolean") throw new Error(`observations.candidateMetadata.${name} must be boolean`);
|
|
194
|
+
}
|
|
195
|
+
result.candidateMetadata = { ...metadata };
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function normalizeBenchmarkResult(input, { requireDerived = false } = {}) {
|
|
201
|
+
const value = structuredClone(requireObject(input, "benchmark result"));
|
|
202
|
+
requireExactKeys(value, [
|
|
203
|
+
"schemaVersion", "benchmark", "taskchefVersion", "runId", "startedAt", "completedAt",
|
|
204
|
+
"workload", "task", "stages", "validation", "observations", "summary",
|
|
205
|
+
], "benchmark result");
|
|
206
|
+
if (value.schemaVersion !== 1) throw new Error("schemaVersion must be 1");
|
|
207
|
+
if (value.benchmark !== BENCHMARK_NAME) {
|
|
208
|
+
throw new Error(`benchmark must be ${BENCHMARK_NAME}`);
|
|
209
|
+
}
|
|
210
|
+
requireString(value.taskchefVersion, "taskchefVersion");
|
|
211
|
+
if (!SEMVER_PATTERN.test(value.taskchefVersion)) throw new Error("taskchefVersion must be a semantic version");
|
|
212
|
+
requireString(value.runId, "runId");
|
|
213
|
+
const startedMs = timestamp(value.startedAt, "startedAt");
|
|
214
|
+
const completedMs = timestamp(value.completedAt, "completedAt");
|
|
215
|
+
if (completedMs < startedMs) throw new Error("completedAt must not precede startedAt");
|
|
216
|
+
|
|
217
|
+
const workload = requireObject(value.workload, "workload");
|
|
218
|
+
requireExactKeys(workload, ["project", "title", "prompt"], "workload");
|
|
219
|
+
requireString(workload.project, "workload.project");
|
|
220
|
+
requireString(workload.title, "workload.title");
|
|
221
|
+
requireString(workload.prompt, "workload.prompt");
|
|
222
|
+
if (/<!--\s*taskchef_id=/i.test(workload.prompt)) {
|
|
223
|
+
throw new Error("workload.prompt must not contain a TaskChef marker");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const task = requireObject(value.task, "task");
|
|
227
|
+
requireExactKeys(task, [
|
|
228
|
+
"taskId", "threadId", "clientThreadId", "recorded", "resolution", "resolutionAttempts",
|
|
229
|
+
], "task");
|
|
230
|
+
task.taskId = nullableString(task.taskId, "task.taskId");
|
|
231
|
+
if (task.taskId !== null && !UUID_PATTERN.test(task.taskId)) {
|
|
232
|
+
throw new Error("task.taskId must be a lowercase UUID");
|
|
233
|
+
}
|
|
234
|
+
task.threadId = nullableString(task.threadId, "task.threadId");
|
|
235
|
+
task.clientThreadId = nullableString(task.clientThreadId, "task.clientThreadId");
|
|
236
|
+
if (task.threadId !== null && task.threadId.trim().toLowerCase().startsWith("local:")) {
|
|
237
|
+
throw new Error("task.threadId must not use the provisional local: namespace");
|
|
238
|
+
}
|
|
239
|
+
if (task.threadId !== null && task.threadId === task.clientThreadId) {
|
|
240
|
+
throw new Error("task.threadId must differ from task.clientThreadId");
|
|
241
|
+
}
|
|
242
|
+
if (typeof task.recorded !== "boolean") throw new Error("task.recorded must be boolean");
|
|
243
|
+
requireString(task.resolution, "task.resolution");
|
|
244
|
+
if (!RESOLUTIONS.has(task.resolution)) throw new Error("task.resolution is invalid");
|
|
245
|
+
if (!Number.isInteger(task.resolutionAttempts) || task.resolutionAttempts < 0) {
|
|
246
|
+
throw new Error("task.resolutionAttempts must be a nonnegative integer");
|
|
247
|
+
}
|
|
248
|
+
if (task.resolutionAttempts > 2) throw new Error("task.resolutionAttempts must not exceed 2");
|
|
249
|
+
|
|
250
|
+
if (!Array.isArray(value.stages)) throw new Error("stages must be an array");
|
|
251
|
+
const stagesByName = new Map();
|
|
252
|
+
let previousCompletedMs = startedMs;
|
|
253
|
+
for (const [index, stage] of value.stages.entries()) {
|
|
254
|
+
requireObject(stage, `stages[${index}]`);
|
|
255
|
+
requireExactKeys(stage, ["name", "startedAt", "completedAt", "outcome", "durationMs"], `stages[${index}]`);
|
|
256
|
+
requireString(stage.name, `stages[${index}].name`);
|
|
257
|
+
if (!STAGE_NAMES.includes(stage.name)) throw new Error(`unknown stage ${stage.name}`);
|
|
258
|
+
if (stagesByName.has(stage.name)) throw new Error(`duplicate stage ${stage.name}`);
|
|
259
|
+
const stageStartedMs = timestamp(stage.startedAt, `${stage.name}.startedAt`);
|
|
260
|
+
const stageCompletedMs = timestamp(stage.completedAt, `${stage.name}.completedAt`);
|
|
261
|
+
if (stageCompletedMs < stageStartedMs) {
|
|
262
|
+
throw new Error(`${stage.name}.completedAt must not precede startedAt`);
|
|
263
|
+
}
|
|
264
|
+
if (stageStartedMs < startedMs || stageCompletedMs > completedMs) {
|
|
265
|
+
throw new Error(`${stage.name} must fall within the benchmark run`);
|
|
266
|
+
}
|
|
267
|
+
if (stageStartedMs < previousCompletedMs) throw new Error("stages must be ordered and non-overlapping");
|
|
268
|
+
requireString(stage.outcome, `${stage.name}.outcome`);
|
|
269
|
+
if (!STAGE_OUTCOMES[stage.name].has(stage.outcome)) {
|
|
270
|
+
throw new Error(`${stage.name}.outcome is invalid`);
|
|
271
|
+
}
|
|
272
|
+
const durationMs = stageCompletedMs - stageStartedMs;
|
|
273
|
+
if (requireDerived && stage.durationMs === undefined) {
|
|
274
|
+
throw new Error(`${stage.name}.durationMs is required in a saved result`);
|
|
275
|
+
}
|
|
276
|
+
if (stage.durationMs !== undefined && stage.durationMs !== durationMs) {
|
|
277
|
+
throw new Error(`${stage.name}.durationMs does not match its timestamps`);
|
|
278
|
+
}
|
|
279
|
+
stage.durationMs = durationMs;
|
|
280
|
+
stagesByName.set(stage.name, stage);
|
|
281
|
+
previousCompletedMs = stageCompletedMs;
|
|
282
|
+
}
|
|
283
|
+
if (!stagesByName.has(STAGE_NAMES[0])) throw new Error(`missing required stage ${STAGE_NAMES[0]}`);
|
|
284
|
+
const resolutionStagePresent = stagesByName.has("resolve-provisional");
|
|
285
|
+
if (task.resolutionAttempts > 0 && !resolutionStagePresent) {
|
|
286
|
+
throw new Error("resolutionAttempts must agree with the resolve-provisional stage");
|
|
287
|
+
}
|
|
288
|
+
if (task.resolutionAttempts === 0 && resolutionStagePresent) {
|
|
289
|
+
throw new Error("zero resolutionAttempts must omit the resolve-provisional stage");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!value.stages.every((stage, index) => stage.name === STAGE_NAMES[index])) {
|
|
293
|
+
throw new Error("stages must use the documented workflow order");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const preparationOutcome = stagesByName.get("prepare-and-list-projects").outcome;
|
|
297
|
+
const creationOutcome = stagesByName.get("create-thread")?.outcome;
|
|
298
|
+
const recordingOutcome = stagesByName.get("record-task")?.outcome;
|
|
299
|
+
const resolutionOutcome = stagesByName.get("resolve-provisional")?.outcome;
|
|
300
|
+
if (preparationOutcome === "failed") {
|
|
301
|
+
if (
|
|
302
|
+
value.stages.length !== 1 || task.taskId !== null || task.threadId !== null || task.clientThreadId !== null ||
|
|
303
|
+
task.recorded || task.resolution !== "unresolved" || task.resolutionAttempts !== 0
|
|
304
|
+
) throw new Error("failed preparation must stop the workflow unresolved");
|
|
305
|
+
} else if (task.taskId === null || creationOutcome === undefined) {
|
|
306
|
+
throw new Error("successful preparation requires a task ID and creation stage");
|
|
307
|
+
}
|
|
308
|
+
if (creationOutcome !== undefined && creationOutcome !== "failed" && recordingOutcome === undefined) {
|
|
309
|
+
throw new Error("successful creation requires a record-task stage");
|
|
310
|
+
}
|
|
311
|
+
if (recordingOutcome !== undefined && (recordingOutcome === "recorded") !== task.recorded) {
|
|
312
|
+
throw new Error("record-task outcome must agree with task.recorded");
|
|
313
|
+
}
|
|
314
|
+
if (creationOutcome === "durable") {
|
|
315
|
+
if (task.resolution !== "immediate" || task.threadId === null || resolutionStagePresent) {
|
|
316
|
+
throw new Error("durable creation must be an immediate durable resolution");
|
|
317
|
+
}
|
|
318
|
+
} else if (creationOutcome === "provisional") {
|
|
319
|
+
if (task.clientThreadId === null) throw new Error("provisional creation requires a clientThreadId");
|
|
320
|
+
if (task.recorded) {
|
|
321
|
+
if (task.resolutionAttempts === 0) {
|
|
322
|
+
if (resolutionStagePresent || task.resolution !== "unresolved" || task.threadId !== null) {
|
|
323
|
+
throw new Error("zero-attempt provisional creation must remain unresolved");
|
|
324
|
+
}
|
|
325
|
+
} else if (
|
|
326
|
+
!resolutionStagePresent ||
|
|
327
|
+
(resolutionOutcome !== task.resolution && !(task.resolution === "unresolved" && resolutionOutcome === "failed"))
|
|
328
|
+
) {
|
|
329
|
+
throw new Error("recorded provisional creation must agree with its resolution stage");
|
|
330
|
+
}
|
|
331
|
+
if ((task.resolution === "unresolved") !== (task.threadId === null)) {
|
|
332
|
+
throw new Error("provisional resolution must agree with task.threadId");
|
|
333
|
+
}
|
|
334
|
+
} else if (
|
|
335
|
+
resolutionStagePresent || task.resolution !== "unresolved" || task.threadId !== null || task.resolutionAttempts !== 0
|
|
336
|
+
) {
|
|
337
|
+
throw new Error("unrecorded provisional creation must remain unresolved");
|
|
338
|
+
}
|
|
339
|
+
} else if (
|
|
340
|
+
creationOutcome === "failed" && (
|
|
341
|
+
value.stages.length !== 2 || task.resolution !== "unresolved" || task.threadId !== null ||
|
|
342
|
+
task.clientThreadId !== null || task.recorded || resolutionStagePresent
|
|
343
|
+
)
|
|
344
|
+
) {
|
|
345
|
+
throw new Error("failed creation must remain unrecorded and unresolved");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const validation = requireObject(value.validation, "validation");
|
|
349
|
+
const validationNames = [
|
|
350
|
+
"recordVerified",
|
|
351
|
+
"markerVerified",
|
|
352
|
+
"outputVerified",
|
|
353
|
+
"candidateFilterEffective",
|
|
354
|
+
];
|
|
355
|
+
requireExactKeys(validation, validationNames, "validation");
|
|
356
|
+
for (const name of validationNames) {
|
|
357
|
+
if (typeof validation[name] !== "boolean") throw new Error(`validation.${name} must be boolean`);
|
|
358
|
+
}
|
|
359
|
+
if (validation.recordVerified && !task.recorded) throw new Error("recordVerified requires a recorded task");
|
|
360
|
+
if (validation.markerVerified && task.threadId === null) throw new Error("markerVerified requires a durable threadId");
|
|
361
|
+
if (validation.outputVerified && task.threadId === null) throw new Error("outputVerified requires a durable threadId");
|
|
362
|
+
|
|
363
|
+
value.observations = normalizeObservations(
|
|
364
|
+
value.observations,
|
|
365
|
+
startedMs,
|
|
366
|
+
completedMs,
|
|
367
|
+
stagesByName.get("prepare-and-list-projects"),
|
|
368
|
+
stagesByName.get("create-thread"),
|
|
369
|
+
stagesByName.get("record-task"),
|
|
370
|
+
stagesByName.get("resolve-provisional"),
|
|
371
|
+
task.resolutionAttempts,
|
|
372
|
+
);
|
|
373
|
+
if (value.observations === undefined) delete value.observations;
|
|
374
|
+
const snapshots = value.observations?.resolutionSnapshots ?? [];
|
|
375
|
+
if (
|
|
376
|
+
snapshots.length > 0 && snapshots.length !== task.resolutionAttempts &&
|
|
377
|
+
!(resolutionOutcome === "failed" && snapshots.length === task.resolutionAttempts - 1)
|
|
378
|
+
) {
|
|
379
|
+
throw new Error("resolutionSnapshots must account for every fallback attempt");
|
|
380
|
+
}
|
|
381
|
+
if (
|
|
382
|
+
task.resolutionAttempts === 2 && snapshots.length !== 2 &&
|
|
383
|
+
!(resolutionOutcome === "failed" && snapshots.length === 1)
|
|
384
|
+
) {
|
|
385
|
+
throw new Error("two resolution attempts require two fallback snapshots");
|
|
386
|
+
}
|
|
387
|
+
if (task.resolution === "native" && (task.resolutionAttempts !== 1 || snapshots.length !== 0)) {
|
|
388
|
+
throw new Error("native resolution requires exactly one non-snapshot attempt");
|
|
389
|
+
}
|
|
390
|
+
if (task.resolution === "discovered") {
|
|
391
|
+
if (
|
|
392
|
+
snapshots.length === 0 || snapshots.at(-1).exactMatchCount !== 1 ||
|
|
393
|
+
snapshots.at(-1).resolveWriteOutcome !== "succeeded"
|
|
394
|
+
) {
|
|
395
|
+
throw new Error("discovered resolution requires one successfully persisted final snapshot match");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (snapshots.some(
|
|
399
|
+
(snapshot) => snapshot.attempt < task.resolutionAttempts && snapshot.exactMatchCount > 0,
|
|
400
|
+
)) {
|
|
401
|
+
throw new Error("any nonzero snapshot match count must terminate fallback resolution");
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
task.resolution === "unresolved" && snapshots.some(
|
|
405
|
+
(snapshot) => snapshot.resolveWriteOutcome === "succeeded",
|
|
406
|
+
)
|
|
407
|
+
) {
|
|
408
|
+
throw new Error("unresolved resolution cannot have a successful resolve write");
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
snapshots.some((snapshot) => snapshot.resolveWriteOutcome === "failed") &&
|
|
412
|
+
resolutionOutcome !== "failed"
|
|
413
|
+
) {
|
|
414
|
+
throw new Error("a failed resolve write requires a failed resolution stage");
|
|
415
|
+
}
|
|
416
|
+
if (
|
|
417
|
+
resolutionStagePresent && snapshots.length === 0 &&
|
|
418
|
+
!["native", "unresolved"].includes(task.resolution)
|
|
419
|
+
) {
|
|
420
|
+
throw new Error("non-snapshot resolution must be native or unresolved");
|
|
421
|
+
}
|
|
422
|
+
if (snapshots.length === 1 && snapshots[0].exactMatchCount === 0 && task.resolutionAttempts !== 2) {
|
|
423
|
+
throw new Error("a completed first snapshot with zero matches requires the second fallback attempt");
|
|
424
|
+
}
|
|
425
|
+
const filterEffective = snapshots.length > 0 && snapshots.every(
|
|
426
|
+
(snapshot) => snapshot.candidateCount < snapshot.recentTaskCount,
|
|
427
|
+
);
|
|
428
|
+
if (validation.candidateFilterEffective !== filterEffective) {
|
|
429
|
+
throw new Error("candidateFilterEffective must match resolution snapshot counts");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const measuredStageMs = value.stages.reduce((total, stage) => total + stage.durationMs, 0);
|
|
433
|
+
const totalWallMs = completedMs - startedMs;
|
|
434
|
+
const summary = {
|
|
435
|
+
totalWallMs,
|
|
436
|
+
measuredStageMs,
|
|
437
|
+
orchestrationOverheadMs: totalWallMs - measuredStageMs,
|
|
438
|
+
resolved: task.threadId !== null,
|
|
439
|
+
resolutionAttempts: task.resolutionAttempts,
|
|
440
|
+
};
|
|
441
|
+
if (requireDerived && value.summary === undefined) {
|
|
442
|
+
throw new Error("summary is required in a saved result");
|
|
443
|
+
}
|
|
444
|
+
if (value.summary !== undefined) {
|
|
445
|
+
const suppliedSummary = requireObject(value.summary, "summary");
|
|
446
|
+
requireExactKeys(suppliedSummary, Object.keys(summary), "summary");
|
|
447
|
+
if (Object.entries(summary).some(([name, expected]) => suppliedSummary[name] !== expected)) {
|
|
448
|
+
throw new Error("summary does not match the derived benchmark summary");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
value.summary = summary;
|
|
452
|
+
return value;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function timestampFilename(iso) {
|
|
456
|
+
return iso.replaceAll(":", "-");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function writeBenchmarkResult(input, outputDirectory) {
|
|
460
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
461
|
+
const result = normalizeBenchmarkResult({ ...input, taskchefVersion: packageJson.version });
|
|
462
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
463
|
+
const filename = `${timestampFilename(result.startedAt)}-${BENCHMARK_NAME}.json`;
|
|
464
|
+
const outputPath = path.join(outputDirectory, filename);
|
|
465
|
+
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, { flag: "wx" });
|
|
466
|
+
return { outputPath, result };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function cleanBenchmarkResults(outputDirectory) {
|
|
470
|
+
const entries = await readdir(outputDirectory, { withFileTypes: true }).catch((error) => {
|
|
471
|
+
if (error.code === "ENOENT") return [];
|
|
472
|
+
throw error;
|
|
473
|
+
});
|
|
474
|
+
const removed = [];
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
if (!entry.isFile() || !managedResultFilename(entry.name)) continue;
|
|
477
|
+
await unlink(path.join(outputDirectory, entry.name));
|
|
478
|
+
removed.push(entry.name);
|
|
479
|
+
}
|
|
480
|
+
return removed.sort();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function readJsonStdin() {
|
|
484
|
+
if (process.stdin.isTTY) throw new Error("write requires non-interactive JSON on stdin");
|
|
485
|
+
let input = "";
|
|
486
|
+
process.stdin.setEncoding("utf8");
|
|
487
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
488
|
+
if (input.trim().length === 0) throw new Error("expected benchmark JSON on stdin");
|
|
489
|
+
return JSON.parse(input);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function main(args) {
|
|
493
|
+
const command = args[0];
|
|
494
|
+
const target = path.resolve(args[1] ?? "reports/e2e-benchmarks");
|
|
495
|
+
if (command === "write") {
|
|
496
|
+
const { outputPath, result } = await writeBenchmarkResult(await readJsonStdin(), target);
|
|
497
|
+
process.stdout.write(`${JSON.stringify({ outputPath, summary: result.summary })}\n`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (command === "validate") {
|
|
501
|
+
const result = normalizeBenchmarkResult(JSON.parse(await readFile(target, "utf8")), { requireDerived: true });
|
|
502
|
+
process.stdout.write(`${JSON.stringify({ inputPath: target, summary: result.summary })}\n`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (command === "clean") {
|
|
506
|
+
process.stdout.write(`${JSON.stringify({ outputDirectory: target, removed: await cleanBenchmarkResults(target) })}\n`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
throw new Error("usage: e2e-benchmark.js <write|validate|clean> [path]");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
513
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
514
|
+
process.stderr.write(`${error.message}\n`);
|
|
515
|
+
process.exitCode = 1;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
@@ -82,10 +82,14 @@ for all deterministic workspace and task-record operations.
|
|
|
82
82
|
ID, call it exactly once with a timeout of at most 30 seconds. Do not invent
|
|
83
83
|
an operation or pass the provisional ID to tools that require `threadId`.
|
|
84
84
|
- When no native operation is available, take at most two `list_threads`
|
|
85
|
-
snapshots with limit 50
|
|
86
|
-
result.
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
snapshots with limit 50. The nominal schedule is 10 and 30 seconds after
|
|
86
|
+
the provisional result. Treat the first checkpoint as due, not expired: if
|
|
87
|
+
nullable recording or other required work finishes after 10 seconds, take
|
|
88
|
+
the first snapshot immediately. Start the second snapshot no sooner than
|
|
89
|
+
20 seconds after the first snapshot actually started. Useful candidate
|
|
90
|
+
work counts toward that interval; when it finishes sooner, wait only the
|
|
91
|
+
remainder. Never suppress either snapshot merely because an earlier step
|
|
92
|
+
ran late, and never take a third snapshot.
|
|
89
93
|
- Filter Codex candidates by the expected host, project, creation time
|
|
90
94
|
(allow five seconds of clock skew), and worktree environment whenever
|
|
91
95
|
those fields are present. Use the title only to prioritize reads; Codex
|
|
@@ -135,3 +139,28 @@ delegated input contains an unresolved task's exact marker, run
|
|
|
135
139
|
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json`.
|
|
136
140
|
Never edit `tasks.jsonl` directly. The CLI permits only an idempotent one-way
|
|
137
141
|
transition from `threadId: null` to one unique durable thread ID.
|
|
142
|
+
|
|
143
|
+
## End-to-end evaluation
|
|
144
|
+
|
|
145
|
+
When explicitly asked to benchmark delegation, measure one real task without
|
|
146
|
+
waiting for executor completion. Start from
|
|
147
|
+
`<plugin-root>/assets/e2e-benchmark-example.json`. Capture one ISO start/end
|
|
148
|
+
interval for the parallel preparation/project-list operation, followed by
|
|
149
|
+
sequential non-overlapping creation, recording, and optional provisional
|
|
150
|
+
resolution stages. Follow the complete schema and workflow transitions in
|
|
151
|
+
`<plugin-root>/SPEC.md`; the example is the durable-success starting shape.
|
|
152
|
+
Omit later stages when an earlier operation stops the workflow.
|
|
153
|
+
Fallback snapshot observations additionally require `recentTaskCount`,
|
|
154
|
+
`candidateCount`, `exactMatchCount`, and `resolveWriteMs`. For each snapshot,
|
|
155
|
+
also record `resolveWriteOutcome` as `not-attempted`, `succeeded`, or `failed`;
|
|
156
|
+
a unique match whose atomic write fails remains unresolved. After the bounded
|
|
157
|
+
workflow ends, verify the canonical task record once and, when resolved, read
|
|
158
|
+
the task once to verify the requested output. A failed verification remains a
|
|
159
|
+
false validation flag even when the corresponding operation succeeded. Feed
|
|
160
|
+
one exact JSON value to
|
|
161
|
+
`node <plugin-root>/scripts/e2e-benchmark.js write <output-directory>` on closed,
|
|
162
|
+
non-interactive stdin. Keep timestamped results in the TaskChef source
|
|
163
|
+
repository's ignored `reports/e2e-benchmarks/` directory. Never include hidden
|
|
164
|
+
reasoning or transcripts. Use the script's `clean` command before establishing
|
|
165
|
+
a replacement baseline. Mark candidate filtering effective only when the
|
|
166
|
+
fallback snapshot narrows reads below the complete recent-task window.
|
package/src/delegation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
export const THREAD_RESOLUTION_CHECKPOINTS_MS = Object.freeze([10_000,
|
|
3
|
+
export const THREAD_RESOLUTION_CHECKPOINTS_MS = Object.freeze([10_000, 30_000]);
|
|
4
4
|
export const THREAD_RESOLUTION_TIMEOUT_MS = 30_000;
|
|
5
5
|
export const THREAD_RESOLUTION_RECENT_LIMIT = 50;
|
|
6
6
|
export const THREAD_RESOLUTION_CLOCK_SKEW_MS = 5_000;
|
|
@@ -179,6 +179,9 @@ function validateResolutionSchedule(checkpointsMs, timeoutMs) {
|
|
|
179
179
|
if (!Array.isArray(checkpointsMs) || checkpointsMs.length === 0) {
|
|
180
180
|
throw new Error("checkpointsMs must contain at least one checkpoint");
|
|
181
181
|
}
|
|
182
|
+
if (checkpointsMs.length > 2) {
|
|
183
|
+
throw new Error("checkpointsMs must contain at most two checkpoints");
|
|
184
|
+
}
|
|
182
185
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
|
|
183
186
|
throw new Error("timeoutMs must be positive");
|
|
184
187
|
}
|
|
@@ -189,6 +192,9 @@ function validateResolutionSchedule(checkpointsMs, timeoutMs) {
|
|
|
189
192
|
}
|
|
190
193
|
previous = checkpoint;
|
|
191
194
|
}
|
|
195
|
+
if (checkpointsMs.length === 2 && checkpointsMs[1] - checkpointsMs[0] < 20_000) {
|
|
196
|
+
throw new Error("checkpointsMs must keep two checkpoints at least 20000ms apart");
|
|
197
|
+
}
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
async function inspectCandidates(candidates, {
|
|
@@ -365,7 +371,11 @@ export async function createAndRecordDelegation({
|
|
|
365
371
|
return false;
|
|
366
372
|
};
|
|
367
373
|
|
|
368
|
-
const acceptResolvedThread = async (thread, resolution, attempt, {
|
|
374
|
+
const acceptResolvedThread = async (thread, resolution, attempt, {
|
|
375
|
+
verified = false,
|
|
376
|
+
enforceDeadline = true,
|
|
377
|
+
} = {}) => {
|
|
378
|
+
const canContinue = enforceDeadline ? hasResolutionTime : () => true;
|
|
369
379
|
const threadId = toolIdentifier(thread.id);
|
|
370
380
|
if (
|
|
371
381
|
threadId === null
|
|
@@ -381,18 +391,18 @@ export async function createAndRecordDelegation({
|
|
|
381
391
|
return null;
|
|
382
392
|
}
|
|
383
393
|
const durableThread = { ...thread, id: threadId };
|
|
384
|
-
if (!
|
|
394
|
+
if (!canContinue()) return null;
|
|
385
395
|
if (!verified) {
|
|
386
396
|
const inspected = await inspectCandidates([durableThread], {
|
|
387
397
|
readThread,
|
|
388
398
|
taskId: prepared.id,
|
|
389
399
|
attempt,
|
|
390
|
-
canVerify:
|
|
400
|
+
canVerify: canContinue,
|
|
391
401
|
});
|
|
392
402
|
discoveryErrors.push(...inspected.errors);
|
|
393
403
|
if (inspected.exactMatches.length !== 1 || inspected.errors.length > 0) return null;
|
|
394
404
|
}
|
|
395
|
-
if (!
|
|
405
|
+
if (!canContinue()) return null;
|
|
396
406
|
try {
|
|
397
407
|
await resolveRecordedTask({ id: prepared.id, threadId });
|
|
398
408
|
} catch (error) {
|
|
@@ -470,12 +480,14 @@ export async function createAndRecordDelegation({
|
|
|
470
480
|
let exactMatches = [];
|
|
471
481
|
let ambiguousMatches = [];
|
|
472
482
|
let attemptsMade = 0;
|
|
483
|
+
let previousAttemptStartedAt = resolutionStartedAt;
|
|
473
484
|
for (let index = 0; index < checkpointsMs.length; index += 1) {
|
|
474
485
|
const attempt = index + 1;
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
486
|
+
const intervalMs = index === 0
|
|
487
|
+
? checkpointsMs[0]
|
|
488
|
+
: checkpointsMs[index] - checkpointsMs[index - 1];
|
|
489
|
+
const dueAt = previousAttemptStartedAt + intervalMs;
|
|
490
|
+
const remainingDelayMs = dueAt - now();
|
|
479
491
|
if (remainingDelayMs > 0) {
|
|
480
492
|
try {
|
|
481
493
|
await waitImpl(remainingDelayMs);
|
|
@@ -488,43 +500,36 @@ export async function createAndRecordDelegation({
|
|
|
488
500
|
break;
|
|
489
501
|
}
|
|
490
502
|
}
|
|
491
|
-
|
|
503
|
+
previousAttemptStartedAt = now();
|
|
492
504
|
attemptsMade = attempt;
|
|
493
505
|
let candidates = [];
|
|
494
506
|
let attemptFailed = false;
|
|
495
507
|
try {
|
|
496
508
|
const snapshot = await listThreads({ limit: recentLimit });
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
});
|
|
506
|
-
}
|
|
509
|
+
candidates = filterThreadCandidates(snapshot, {
|
|
510
|
+
excludedThreadIds: provisionalIds,
|
|
511
|
+
hostId: expected.hostId ?? null,
|
|
512
|
+
projectId: expected.projectId ?? target.projectId ?? null,
|
|
513
|
+
title,
|
|
514
|
+
createdAfter,
|
|
515
|
+
environmentType: expected.environmentType ?? target.environment?.type ?? null,
|
|
516
|
+
});
|
|
507
517
|
} catch (error) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
});
|
|
515
|
-
}
|
|
518
|
+
attemptFailed = true;
|
|
519
|
+
discoveryErrors.push({
|
|
520
|
+
attempt,
|
|
521
|
+
operation: "listThreads",
|
|
522
|
+
message: error instanceof Error ? error.message : String(error),
|
|
523
|
+
});
|
|
516
524
|
}
|
|
517
|
-
if (!hasResolutionTime()) break;
|
|
518
525
|
const inspected = await inspectCandidates(candidates, {
|
|
519
526
|
readThread,
|
|
520
527
|
taskId: prepared.id,
|
|
521
528
|
attempt,
|
|
522
|
-
canVerify: hasResolutionTime,
|
|
523
529
|
});
|
|
524
530
|
exactMatches = inspected.exactMatches;
|
|
525
531
|
discoveryErrors.push(...inspected.errors);
|
|
526
532
|
if (inspected.errors.length > 0) attemptFailed = true;
|
|
527
|
-
if (!hasResolutionTime()) break;
|
|
528
533
|
if (exactMatches.length > 1) ambiguousMatches = exactMatches;
|
|
529
534
|
if (
|
|
530
535
|
exactMatches.length === 1
|
|
@@ -536,7 +541,7 @@ export async function createAndRecordDelegation({
|
|
|
536
541
|
exactMatches[0],
|
|
537
542
|
"discovered",
|
|
538
543
|
attempt,
|
|
539
|
-
{ verified: true },
|
|
544
|
+
{ verified: true, enforceDeadline: false },
|
|
540
545
|
);
|
|
541
546
|
if (resolved !== null) return resolved;
|
|
542
547
|
}
|