taskchef 5.0.2 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/BACKLOG.md +25 -0
- package/README.md +7 -0
- package/SPEC.md +30 -13
- package/index.js +1 -0
- package/package.json +3 -1
- package/scripts/benchmark-dispatch-prepare.js +90 -0
- package/skills/taskchef-delegate/SKILL.md +29 -15
- package/src/cli.js +28 -1
- package/src/delegation.js +37 -32
- package/src/workspace.js +47 -9
package/BACKLOG.md
CHANGED
|
@@ -54,3 +54,28 @@ clear data model before implementation.
|
|
|
54
54
|
one of these APIs. Keep exact marker verification before persisting the
|
|
55
55
|
returned durable ID unless the official contract provides equivalent
|
|
56
56
|
correlation guarantees.
|
|
57
|
+
|
|
58
|
+
## Structured task recording tool
|
|
59
|
+
|
|
60
|
+
- Investigate bundling a local stdio MCP server in the TaskChef plugin so Codex
|
|
61
|
+
can call focused `prepare_dispatch`, `record_task`, and `resolve_task` tools
|
|
62
|
+
with structured inputs instead of invoking the data CLI through a shell.
|
|
63
|
+
- Reuse the existing `prepareDispatch`, `recordTask`, and `resolveTask` APIs so
|
|
64
|
+
the MCP layer cannot bypass canonical workspace resolution, exact-field
|
|
65
|
+
validation, locking, atomic replacement, unique durable-thread correlation,
|
|
66
|
+
or one-way nullable resolution.
|
|
67
|
+
- Prototype canonical `~/.agents/taskchef` access before committing to this
|
|
68
|
+
design. Verify how bundled MCP processes interact with Codex filesystem
|
|
69
|
+
sandboxing and plugin-scoped tool approval on every supported local surface.
|
|
70
|
+
- Keep native Codex task creation outside the MCP server unless Codex exposes a
|
|
71
|
+
supported task-creation API to plugins. The intended sequence is structured
|
|
72
|
+
TaskChef preparation, native `create_thread`, then structured TaskChef record
|
|
73
|
+
and optional resolution.
|
|
74
|
+
- Evaluate tool schemas, approval annotations, failure reporting, installation
|
|
75
|
+
and upgrade behavior, process lifetime, and latency against the Phase 1 CLI
|
|
76
|
+
baseline before replacing the CLI path.
|
|
77
|
+
- Relevant official documentation:
|
|
78
|
+
<https://developers.openai.com/plugins/concepts/plugins>,
|
|
79
|
+
<https://developers.openai.com/plugins/concepts/mcp-server>,
|
|
80
|
+
<https://developers.openai.com/plugins/build/mcp-server>, and
|
|
81
|
+
<https://developers.openai.com/plugins/build/plugins>.
|
package/README.md
CHANGED
|
@@ -222,6 +222,7 @@ taskchef project add <path>
|
|
|
222
222
|
taskchef project import [<file> | -]
|
|
223
223
|
taskchef project list
|
|
224
224
|
taskchef project remove <name>
|
|
225
|
+
taskchef dispatch prepare
|
|
225
226
|
taskchef task record
|
|
226
227
|
taskchef task resolve <task-id> --thread-id <thread-id>
|
|
227
228
|
taskchef task show <task-id>
|
|
@@ -234,6 +235,12 @@ Workspace resolution is deterministic: `--workspace <path>`, then the
|
|
|
234
235
|
current directory is never an implicit workspace. Data commands accept
|
|
235
236
|
`--json` for machine-readable output. Run `taskchef help` for every option.
|
|
236
237
|
|
|
238
|
+
`taskchef dispatch prepare --json` performs the read-only preparation used by
|
|
239
|
+
the delegation skill in one process: it resolves the canonical workspace,
|
|
240
|
+
loads and validates configured projects, and returns a generated task UUID,
|
|
241
|
+
preparation timestamp, and exact correlation marker. `task record` accepts one
|
|
242
|
+
JSON value only from closed, non-interactive standard input.
|
|
243
|
+
|
|
237
244
|
### One-time upgrade from an older workspace
|
|
238
245
|
|
|
239
246
|
TaskChef 5 does not include a general migration command. For a one-time upgrade,
|
package/SPEC.md
CHANGED
|
@@ -178,21 +178,30 @@ cases.
|
|
|
178
178
|
|
|
179
179
|
For each assignment, `$taskchef-delegate`:
|
|
180
180
|
|
|
181
|
-
1.
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
1. runs `dispatch prepare` and native Codex project discovery concurrently;
|
|
182
|
+
the preparation command resolves the canonical workspace, loads and
|
|
183
|
+
validates configured projects, and generates the full UUID, preparation
|
|
184
|
+
timestamp, and exact marker in one process
|
|
185
|
+
2. selects one unambiguous configured target and matches its exact path against
|
|
186
|
+
the already-loaded native projects
|
|
187
|
+
3. prefixes the instruction with the prepared exact
|
|
184
188
|
`<!-- taskchef_id=<UUID> -->` marker as the first line, followed by a blank
|
|
185
189
|
line
|
|
186
190
|
4. creates a real Codex task at the exact configured path
|
|
187
|
-
5. appends a task entry immediately
|
|
191
|
+
5. appends a task entry immediately through closed, non-interactive stdin when
|
|
192
|
+
creation returns a durable thread ID; it never opens a TTY or writes a
|
|
193
|
+
temporary record file, and requests canonical-workspace write permission on
|
|
194
|
+
the first attempt when the command sandbox does not allow that path
|
|
188
195
|
6. when creation returns only a provisional client ID, immediately appends the
|
|
189
196
|
marked entry with `threadId: null`, then prefers one native client-ID wait or
|
|
190
197
|
resolution call with a 30-second timeout when Codex exposes one
|
|
191
198
|
7. when no native operation is available, takes at most two recent-thread
|
|
192
|
-
snapshots
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
205
|
8. atomically fills the nullable thread ID after an exact match
|
|
197
206
|
9. returns after recording or after reporting that bounded resolution was
|
|
198
207
|
unresolved, without waiting for executor work completion.
|
|
@@ -202,11 +211,13 @@ Creation-time filtering allows five seconds of clock skew. Candidate reads run
|
|
|
202
211
|
concurrently where the host permits and inspect only structured
|
|
203
212
|
`codexDelegation.input`, never untrusted title, summary, preview, or plain-text
|
|
204
213
|
marker echoes. A native resolver result is verified against the same structured
|
|
205
|
-
marker before persistence. Zero exact matches
|
|
206
|
-
|
|
207
|
-
task-resolution errors leave the already-recorded nullable entry intact.
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
210
221
|
A `clientThreadId`, `pendingWorktreeId`, or ID in the documented provisional
|
|
211
222
|
`local:` namespace remains diagnostic context and is rejected from every path
|
|
212
223
|
that could persist the canonical `threadId` field.
|
|
@@ -217,6 +228,12 @@ helpers with injected thread-tool callbacks, while the skill owns the actual
|
|
|
217
228
|
desktop-tool calls and the CLI remains responsible only for validated data
|
|
218
229
|
operations.
|
|
219
230
|
|
|
231
|
+
`task record` rejects an interactive TTY before reading because its protocol is
|
|
232
|
+
exactly one JSON value followed by EOF. Workspace-lock contention is retried for
|
|
233
|
+
up to seven seconds, while permanent permission failures such as `EPERM` or
|
|
234
|
+
`EACCES` fail immediately so the caller can request the required permission
|
|
235
|
+
without paying the contention retry budget.
|
|
236
|
+
|
|
220
237
|
A failed executor creation produces no entry. If executor creation succeeds but
|
|
221
238
|
the append fails, the executor remains valid and TaskChef tells the user that
|
|
222
239
|
it was not recorded.
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"bin",
|
|
25
25
|
"index.js",
|
|
26
26
|
"SPEC.md",
|
|
27
|
+
"scripts/benchmark-dispatch-prepare.js",
|
|
27
28
|
"src",
|
|
28
29
|
"skills/taskchef-bootstrap",
|
|
29
30
|
"skills/taskchef-delegate",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"yaml": "^2.9.0"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
41
|
+
"benchmark:dispatch": "node scripts/benchmark-dispatch-prepare.js",
|
|
40
42
|
"test": "node --test tests/taskchef.test.js"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { addProject, initializeWorkspace } from "../index.js";
|
|
11
|
+
|
|
12
|
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const cli = path.join(repositoryRoot, "bin", "taskchef.js");
|
|
14
|
+
const sampleCount = Number.parseInt(process.env.TASKCHEF_BENCHMARK_SAMPLES ?? "30", 10);
|
|
15
|
+
if (!Number.isInteger(sampleCount) || sampleCount < 1) {
|
|
16
|
+
throw new Error("TASKCHEF_BENCHMARK_SAMPLES must be a positive integer");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "taskchef-dispatch-benchmark-"));
|
|
20
|
+
const workspace = path.join(root, "workspace");
|
|
21
|
+
const project = path.join(root, "project");
|
|
22
|
+
await mkdir(project);
|
|
23
|
+
await initializeWorkspace(workspace);
|
|
24
|
+
await addProject(workspace, { name: "benchmark", path: project, githubRepos: [] });
|
|
25
|
+
|
|
26
|
+
function runTaskChef(args) {
|
|
27
|
+
return execFileSync(process.execPath, [cli, ...args], { encoding: "utf8" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function measure(operation) {
|
|
31
|
+
const startedAt = performance.now();
|
|
32
|
+
operation();
|
|
33
|
+
return performance.now() - startedAt;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const legacy = [];
|
|
37
|
+
const prepared = [];
|
|
38
|
+
for (let index = 0; index < sampleCount; index += 1) {
|
|
39
|
+
legacy.push(measure(() => {
|
|
40
|
+
runTaskChef(["workspace", "path", "--json", "--workspace", workspace]);
|
|
41
|
+
runTaskChef(["project", "list", "--json", "--workspace", workspace]);
|
|
42
|
+
execFileSync(process.execPath, [
|
|
43
|
+
"-e",
|
|
44
|
+
"const { randomUUID } = require('node:crypto'); JSON.stringify({ taskId: randomUUID(), preparedAt: new Date().toISOString() });",
|
|
45
|
+
]);
|
|
46
|
+
}));
|
|
47
|
+
prepared.push(measure(() => {
|
|
48
|
+
const result = JSON.parse(runTaskChef([
|
|
49
|
+
"dispatch", "prepare", "--json", "--workspace", workspace,
|
|
50
|
+
]));
|
|
51
|
+
if (result.projectCount !== 1 || !result.marker.includes(result.taskId)) {
|
|
52
|
+
throw new Error("dispatch prepare returned an invalid benchmark result");
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function statistics(samples) {
|
|
58
|
+
const sorted = [...samples].sort((left, right) => left - right);
|
|
59
|
+
const percentile = (value) => sorted[Math.floor((sorted.length - 1) * value)];
|
|
60
|
+
return {
|
|
61
|
+
samples: sorted.length,
|
|
62
|
+
medianMs: Number(percentile(0.5).toFixed(2)),
|
|
63
|
+
p95Ms: Number(percentile(0.95).toFixed(2)),
|
|
64
|
+
minMs: Number(sorted[0].toFixed(2)),
|
|
65
|
+
maxMs: Number(sorted.at(-1).toFixed(2)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const legacyStats = statistics(legacy);
|
|
70
|
+
const preparedStats = statistics(prepared);
|
|
71
|
+
process.stdout.write(`${JSON.stringify({
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
comparison: {
|
|
74
|
+
legacy: {
|
|
75
|
+
description: "workspace path + project list + external UUID/timestamp process",
|
|
76
|
+
processCalls: 3,
|
|
77
|
+
...legacyStats,
|
|
78
|
+
},
|
|
79
|
+
dispatchPrepare: {
|
|
80
|
+
description: "dispatch prepare",
|
|
81
|
+
processCalls: 1,
|
|
82
|
+
...preparedStats,
|
|
83
|
+
},
|
|
84
|
+
savedProcessCalls: 2,
|
|
85
|
+
medianSpeedup: Number((legacyStats.medianMs / preparedStats.medianMs).toFixed(2)),
|
|
86
|
+
medianReductionPercent: Number(
|
|
87
|
+
((1 - preparedStats.medianMs / legacyStats.medianMs) * 100).toFixed(1),
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
}, null, 2)}\n`);
|
|
@@ -27,9 +27,11 @@ for all deterministic workspace and task-record operations.
|
|
|
27
27
|
|
|
28
28
|
## Dispatch
|
|
29
29
|
|
|
30
|
-
1.
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
1. In parallel, run `<plugin-root>/bin/taskchef.js dispatch prepare --json`
|
|
31
|
+
and list the native Codex projects once. The prepare command resolves the
|
|
32
|
+
canonical workspace, loads and validates the configured routing targets,
|
|
33
|
+
generates the lowercase full UUID task ID, and returns `preparedAt` plus the
|
|
34
|
+
exact first-line marker. Use
|
|
33
35
|
`$taskchef-bootstrap` if the workspace is missing or unhealthy.
|
|
34
36
|
The CLI resolves `--workspace`, then `TASKCHEF_WORKSPACE`, then
|
|
35
37
|
`~/.agents/taskchef`; do not substitute the current project. Reject the
|
|
@@ -45,20 +47,28 @@ for all deterministic workspace and task-record operations.
|
|
|
45
47
|
suffix. Check that identity against every repository URL of every configured
|
|
46
48
|
project. Route on this evidence only when exactly one configured project
|
|
47
49
|
matches. Ask instead of guessing when no project or several projects match.
|
|
48
|
-
4. Resolve
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
4. Resolve the selected configured path against the already-loaded native
|
|
51
|
+
projects and require an exact match. Do not list native projects again.
|
|
52
|
+
5. Use the task ID, preparation time, and marker returned by the preparation
|
|
53
|
+
command.
|
|
54
|
+
Prefix the complete executor instruction with
|
|
55
|
+
exactly `<!-- taskchef_id=<full UUID> -->` as the first line, followed by a
|
|
56
|
+
blank line and the instruction body. Preserve this
|
|
52
57
|
marked instruction for recording, and note the creation time.
|
|
53
58
|
Do not take a pre-creation thread snapshot; the exact random marker is the
|
|
54
59
|
correlation key.
|
|
55
60
|
6. Create one real Codex task using the exact configured project, a local
|
|
56
61
|
environment on its executor host, the marked instruction, and a short title.
|
|
57
62
|
7. When `create_thread` returns a durable `threadId`, immediately run
|
|
58
|
-
`<plugin-root>/bin/taskchef.js task record --json
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
`<plugin-root>/bin/taskchef.js task record --json` in one non-interactive
|
|
64
|
+
invocation whose stdin contains exactly one JSON value and is closed at
|
|
65
|
+
launch. Never open an interactive TTY and never create a temporary record
|
|
66
|
+
file. When the canonical workspace is outside the command sandbox's writable
|
|
67
|
+
roots, request permission for that exact write on the first attempt; never
|
|
68
|
+
use a failed sandbox write as a permission probe. Send exactly `id`,
|
|
69
|
+
`project`, `title`, `instruction`, and `threadId`. Use the configured project
|
|
70
|
+
path for `project`, and send the marked instruction unchanged.
|
|
71
|
+
Never persist a provisional `clientThreadId` or
|
|
62
72
|
`pendingWorktreeId` as `threadId`. Never persist `hostId`, status, results,
|
|
63
73
|
transcripts, or hidden reasoning.
|
|
64
74
|
8. When creation returns only `clientThreadId` or `pendingWorktreeId`, keep it
|
|
@@ -72,10 +82,14 @@ for all deterministic workspace and task-record operations.
|
|
|
72
82
|
ID, call it exactly once with a timeout of at most 30 seconds. Do not invent
|
|
73
83
|
an operation or pass the provisional ID to tools that require `threadId`.
|
|
74
84
|
- When no native operation is available, take at most two `list_threads`
|
|
75
|
-
snapshots with limit 50
|
|
76
|
-
result.
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
79
93
|
- Filter Codex candidates by the expected host, project, creation time
|
|
80
94
|
(allow five seconds of clock skew), and worktree environment whenever
|
|
81
95
|
those fields are present. Use the title only to prioritize reads; Codex
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
importProjects,
|
|
13
13
|
initializeWorkspace,
|
|
14
14
|
listProjects,
|
|
15
|
+
prepareDispatch,
|
|
15
16
|
readTask,
|
|
16
17
|
recordTask,
|
|
17
18
|
removeProject,
|
|
@@ -27,7 +28,16 @@ async function readStdin() {
|
|
|
27
28
|
return input;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export function assertTaskRecordStdin(stdin = process.stdin) {
|
|
32
|
+
if (stdin.isTTY) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"task record requires non-interactive JSON on standard input; pipe one JSON value and close stdin",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
async function readJsonStdin() {
|
|
40
|
+
assertTaskRecordStdin();
|
|
31
41
|
const input = await readStdin();
|
|
32
42
|
if (input.trim().length === 0) throw new Error("expected JSON on standard input");
|
|
33
43
|
return JSON.parse(input);
|
|
@@ -248,6 +258,20 @@ async function projectList(args) {
|
|
|
248
258
|
return 0;
|
|
249
259
|
}
|
|
250
260
|
|
|
261
|
+
async function dispatchPrepare(args) {
|
|
262
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
263
|
+
const resolution = workspaceSelection(args);
|
|
264
|
+
const prepared = await prepareDispatch(resolution.workspace);
|
|
265
|
+
prepared.workspaceSource = resolution.source;
|
|
266
|
+
print(prepared, args, (value) => [
|
|
267
|
+
`Workspace: ${value.workspace}`,
|
|
268
|
+
`Task ID: ${value.taskId}`,
|
|
269
|
+
`Prepared: ${value.preparedAt}`,
|
|
270
|
+
`Projects: ${value.projectCount}`,
|
|
271
|
+
].join("\n"));
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
async function projectRemove(args) {
|
|
252
276
|
if (!args[2] || args[2].startsWith("--")) throw new Error("project remove requires a name");
|
|
253
277
|
validateCommandArgs(args, 3, {
|
|
@@ -332,13 +356,15 @@ Usage:
|
|
|
332
356
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
333
357
|
taskchef project list [--json] [--workspace <path>]
|
|
334
358
|
taskchef project remove <name> [--json] [--workspace <path>]
|
|
359
|
+
taskchef dispatch prepare [--json] [--workspace <path>]
|
|
335
360
|
taskchef task record [--json] [--workspace <path>]
|
|
336
361
|
taskchef task resolve <task-id> --thread-id <thread-id> [--json] [--workspace <path>]
|
|
337
362
|
taskchef task show <task-id> [--json] [--workspace <path>]
|
|
338
363
|
taskchef task list [--project <name-or-path>] [--ascending] [--json] [--workspace <path>]
|
|
339
364
|
taskchef task summary [--json] [--workspace <path>]
|
|
340
365
|
|
|
341
|
-
Task record reads JSON from standard input.
|
|
366
|
+
Task record reads one JSON value from closed, non-interactive standard input.
|
|
367
|
+
Project import reads a JSON
|
|
342
368
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
343
369
|
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
344
370
|
~/.agents/taskchef.
|
|
@@ -357,6 +383,7 @@ export async function runCli(args) {
|
|
|
357
383
|
if (args[0] === "project" && args[1] === "import") return projectImport(args);
|
|
358
384
|
if (args[0] === "project" && args[1] === "list") return projectList(args);
|
|
359
385
|
if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
|
|
386
|
+
if (args[0] === "dispatch" && args[1] === "prepare") return dispatchPrepare(args);
|
|
360
387
|
if (args[0] === "task" && args[1] === "record") return taskRecord(args);
|
|
361
388
|
if (args[0] === "task" && args[1] === "resolve") return taskResolve(args);
|
|
362
389
|
if (args[0] === "task" && args[1] === "show" && args[2]) return taskShow(args);
|
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
|
}
|
package/src/workspace.js
CHANGED
|
@@ -19,7 +19,11 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { promisify } from "node:util";
|
|
21
21
|
import lockfile from "proper-lockfile";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
normalizeDurableThreadId,
|
|
24
|
+
parseTaskChefMarker,
|
|
25
|
+
taskChefMarker,
|
|
26
|
+
} from "./delegation.js";
|
|
23
27
|
import {
|
|
24
28
|
canonicalGithubRepository,
|
|
25
29
|
normalizeGithubRepositories,
|
|
@@ -157,15 +161,30 @@ function assertWorkspaceOutsideProject(workspaceRoot, projectPath) {
|
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
async function
|
|
164
|
+
export async function acquireWorkspaceLock(workspaceRoot, {
|
|
165
|
+
lock = lockfile.lock,
|
|
166
|
+
waitImpl = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
167
|
+
} = {}) {
|
|
161
168
|
const lockPath = path.join(workspaceRoot, WORKSPACE_LOCK_NAME);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
for (let attempt = 0; attempt <= 70; attempt += 1) {
|
|
170
|
+
try {
|
|
171
|
+
return await lock(workspaceRoot, {
|
|
172
|
+
realpath: false,
|
|
173
|
+
lockfilePath: lockPath,
|
|
174
|
+
stale: 600_000,
|
|
175
|
+
update: 10_000,
|
|
176
|
+
retries: 0,
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code !== "ELOCKED" || attempt === 70) throw error;
|
|
180
|
+
await waitImpl(100);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw new Error("workspace lock retry loop ended unexpectedly");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function withWorkspaceLock(workspaceRoot, operation) {
|
|
187
|
+
const release = await acquireWorkspaceLock(workspaceRoot);
|
|
169
188
|
try {
|
|
170
189
|
return await operation();
|
|
171
190
|
} finally {
|
|
@@ -628,6 +647,25 @@ export async function listProjects(workspaceRoot) {
|
|
|
628
647
|
return [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
|
|
629
648
|
}
|
|
630
649
|
|
|
650
|
+
export async function prepareDispatch(workspaceRoot, {
|
|
651
|
+
taskId = randomUUID(),
|
|
652
|
+
now = () => new Date().toISOString(),
|
|
653
|
+
} = {}) {
|
|
654
|
+
const workspace = await realpath(path.resolve(workspaceRoot));
|
|
655
|
+
const projects = await listProjects(workspace);
|
|
656
|
+
const marker = taskChefMarker(taskId);
|
|
657
|
+
const preparedAt = requireTimestamp(now(), "preparedAt");
|
|
658
|
+
return {
|
|
659
|
+
schemaVersion: 1,
|
|
660
|
+
workspace,
|
|
661
|
+
taskId,
|
|
662
|
+
preparedAt,
|
|
663
|
+
marker,
|
|
664
|
+
projectCount: projects.length,
|
|
665
|
+
projects,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
631
669
|
export async function addProject(workspaceRoot, input) {
|
|
632
670
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
633
671
|
return withWorkspaceLock(root, async () => {
|