taskchef 5.0.1 → 5.1.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/BACKLOG.md +25 -0
- package/README.md +11 -2
- package/SPEC.md +29 -9
- package/index.js +1 -0
- package/package.json +3 -1
- package/scripts/benchmark-dispatch-prepare.js +90 -0
- package/skills/taskchef-delegate/SKILL.md +31 -17
- package/skills/taskchef-report/SKILL.md +4 -2
- package/src/cli.js +28 -1
- package/src/delegation.js +15 -7
- package/src/workspace.js +59 -15
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
|
@@ -119,7 +119,9 @@ an unresolved task's nullable thread ID. The log records what TaskChef sent,
|
|
|
119
119
|
when it sent it, which project it selected, and which Codex task received the
|
|
120
120
|
work.
|
|
121
121
|
|
|
122
|
-
Every delegated instruction
|
|
122
|
+
Every delegated instruction begins with a unique
|
|
123
|
+
`<!-- taskchef_id=<UUID> -->` marker followed by a blank line. The valid HTML
|
|
124
|
+
comment stays invisible in rendered Markdown.
|
|
123
125
|
If worktree creation does not return a thread ID immediately, TaskChef records
|
|
124
126
|
the marked delegation as unresolved, then waits briefly for the durable task.
|
|
125
127
|
It prefers a native Codex client-ID resolver when available and otherwise makes
|
|
@@ -220,6 +222,7 @@ taskchef project add <path>
|
|
|
220
222
|
taskchef project import [<file> | -]
|
|
221
223
|
taskchef project list
|
|
222
224
|
taskchef project remove <name>
|
|
225
|
+
taskchef dispatch prepare
|
|
223
226
|
taskchef task record
|
|
224
227
|
taskchef task resolve <task-id> --thread-id <thread-id>
|
|
225
228
|
taskchef task show <task-id>
|
|
@@ -232,6 +235,12 @@ Workspace resolution is deterministic: `--workspace <path>`, then the
|
|
|
232
235
|
current directory is never an implicit workspace. Data commands accept
|
|
233
236
|
`--json` for machine-readable output. Run `taskchef help` for every option.
|
|
234
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
|
+
|
|
235
244
|
### One-time upgrade from an older workspace
|
|
236
245
|
|
|
237
246
|
TaskChef 5 does not include a general migration command. For a one-time upgrade,
|
|
@@ -316,7 +325,7 @@ task lines remain readable without an eager rewrite of the append-only history.
|
|
|
316
325
|
`project` value is the exact configured project path:
|
|
317
326
|
|
|
318
327
|
```sh
|
|
319
|
-
printf '%s\n' '{"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":"/workspace/payments","title":"Add retry logs","instruction":"
|
|
328
|
+
printf '%s\n' '{"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":"/workspace/payments","title":"Add retry logs","instruction":"<!-- taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7 -->\n\nAdd structured logs for failed retries and test them.","threadId":"019f..."}' |
|
|
320
329
|
taskchef task record --json
|
|
321
330
|
```
|
|
322
331
|
|
package/SPEC.md
CHANGED
|
@@ -134,7 +134,7 @@ schedules, task status, results, host information, or the workspace path.
|
|
|
134
134
|
`tasks.jsonl` contains one compact JSON object per line, in append order:
|
|
135
135
|
|
|
136
136
|
```json
|
|
137
|
-
{"schemaVersion":2,"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":{"name":"payments-api","path":"/workspace/payments-api","isGitRepository":true,"githubRepos":["https://github.com/example/payments-api","https://github.com/example/payments-sdk"],"description":"Owns payment authorization, capture, refunds, and provider integrations."},"title":"Add payment retry logs","instruction":"
|
|
137
|
+
{"schemaVersion":2,"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":{"name":"payments-api","path":"/workspace/payments-api","isGitRepository":true,"githubRepos":["https://github.com/example/payments-api","https://github.com/example/payments-sdk"],"description":"Owns payment authorization, capture, refunds, and provider integrations."},"title":"Add payment retry logs","instruction":"<!-- taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7 -->\n\nAdd structured logs for failed payment retries and test them.","threadId":"019f9d46-f42c-7482-9707-3c107bf241ee","createdAt":"2026-08-08T10:00:00.000Z"}
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
- `schemaVersion` identifies the task entry format.
|
|
@@ -142,7 +142,8 @@ schedules, task status, results, host information, or the workspace path.
|
|
|
142
142
|
- `project` is the complete configured project snapshot used for routing.
|
|
143
143
|
- `title` is a short task name.
|
|
144
144
|
- `instruction` is the complete executor instruction, including its first-line
|
|
145
|
-
|
|
145
|
+
`<!-- taskchef_id=<full UUID> -->` correlation marker and the blank line that
|
|
146
|
+
follows it.
|
|
146
147
|
- `threadId` identifies the created Codex task, or is `null` when creation was
|
|
147
148
|
accepted but bounded marker resolution did not find one durable task ID.
|
|
148
149
|
- `createdAt` is the dispatch time as an ISO 8601 timestamp.
|
|
@@ -165,19 +166,32 @@ hidden reasoning, `hostId`, or update timestamps.
|
|
|
165
166
|
|
|
166
167
|
New task entries use schema version 2 and list-valued project snapshots.
|
|
167
168
|
Version 1 entries with string or null repository metadata remain readable and
|
|
168
|
-
normalize to version 2 in API and CLI output.
|
|
169
|
-
|
|
169
|
+
normalize to version 2 in API and CLI output. Historical task entries with the
|
|
170
|
+
old heading-style marker also remain readable. New nullable records, candidate
|
|
171
|
+
matching, and resolution require the exact HTML-comment marker; TaskChef never
|
|
172
|
+
uses an old marker to correlate a thread. Direct records that already have a
|
|
173
|
+
durable thread ID remain marker-independent because they do not use recovery.
|
|
174
|
+
TaskChef does not eagerly rewrite legacy history solely for these compatibility
|
|
175
|
+
cases.
|
|
170
176
|
|
|
171
177
|
## Dispatch workflow
|
|
172
178
|
|
|
173
179
|
For each assignment, `$taskchef-delegate`:
|
|
174
180
|
|
|
175
|
-
1.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
188
|
+
`<!-- taskchef_id=<UUID> -->` marker as the first line, followed by a blank
|
|
189
|
+
line
|
|
179
190
|
4. creates a real Codex task at the exact configured path
|
|
180
|
-
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
|
|
181
195
|
6. when creation returns only a provisional client ID, immediately appends the
|
|
182
196
|
marked entry with `threadId: null`, then prefers one native client-ID wait or
|
|
183
197
|
resolution call with a 30-second timeout when Codex exposes one
|
|
@@ -210,6 +224,12 @@ helpers with injected thread-tool callbacks, while the skill owns the actual
|
|
|
210
224
|
desktop-tool calls and the CLI remains responsible only for validated data
|
|
211
225
|
operations.
|
|
212
226
|
|
|
227
|
+
`task record` rejects an interactive TTY before reading because its protocol is
|
|
228
|
+
exactly one JSON value followed by EOF. Workspace-lock contention is retried for
|
|
229
|
+
up to seven seconds, while permanent permission failures such as `EPERM` or
|
|
230
|
+
`EACCES` fail immediately so the caller can request the required permission
|
|
231
|
+
without paying the contention retry budget.
|
|
232
|
+
|
|
213
233
|
A failed executor creation produces no entry. If executor creation succeeds but
|
|
214
234
|
the append fails, the executor remains valid and TaskChef tells the user that
|
|
215
235
|
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.0
|
|
3
|
+
"version": "5.1.0",
|
|
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,19 +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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
+
marked instruction for recording, and note the creation time.
|
|
58
|
+
Do not take a pre-creation thread snapshot; the exact random marker is the
|
|
59
|
+
correlation key.
|
|
54
60
|
6. Create one real Codex task using the exact configured project, a local
|
|
55
61
|
environment on its executor host, the marked instruction, and a short title.
|
|
56
62
|
7. When `create_thread` returns a durable `threadId`, immediately run
|
|
57
|
-
`<plugin-root>/bin/taskchef.js task record --json
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
72
|
`pendingWorktreeId` as `threadId`. Never persist `hostId`, status, results,
|
|
62
73
|
transcripts, or hidden reasoning.
|
|
63
74
|
8. When creation returns only `clientThreadId` or `pendingWorktreeId`, keep it
|
|
@@ -85,10 +96,13 @@ for all deterministic workspace and task-record operations.
|
|
|
85
96
|
`userMessage.content[].codexDelegation.input`; do not trust titles,
|
|
86
97
|
summaries, previews, plain-text echoes, or assistant output as proof.
|
|
87
98
|
- Accept a candidate only when the structured input's first line is exactly
|
|
88
|
-
the task's
|
|
89
|
-
matches.
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
the task's `<!-- taskchef_id=<full UUID> -->` marker and exactly one
|
|
100
|
+
candidate matches. Require an immediately following blank line. Reject old
|
|
101
|
+
heading-style markers, malformed comments, missing blank separators, and
|
|
102
|
+
marker-like text anywhere else. Apply the same marker verification to a
|
|
103
|
+
thread ID returned by a native resolver. Reject any returned or discovered
|
|
104
|
+
thread ID equal to the provisional identifier or in its `local:` namespace.
|
|
105
|
+
Then use
|
|
92
106
|
the task-resolution command under **Later resolution** to atomically fill
|
|
93
107
|
the nullable field.
|
|
94
108
|
- Treat native-resolution, snapshot, candidate-read, wait, and task-resolution
|
|
@@ -29,8 +29,10 @@ all deterministic task-log operations.
|
|
|
29
29
|
2. Separate entries whose `threadId` is `null`. For those entries, take one
|
|
30
30
|
`list_threads` snapshot with limit 50, filter by available project metadata,
|
|
31
31
|
and inspect candidate structured delegated inputs. Use title only to
|
|
32
|
-
prioritize candidates, never to exclude them.
|
|
33
|
-
|
|
32
|
+
prioritize candidates, never to exclude them. Require the exact first line
|
|
33
|
+
`<!-- taskchef_id=<full lowercase UUID> -->` and an immediately following
|
|
34
|
+
blank line; reject old heading-style markers, malformed comments, and missing
|
|
35
|
+
blank separators. When exactly one candidate has that exact prefix, run
|
|
34
36
|
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json`.
|
|
35
37
|
Do not resolve zero or multiple matches. Report unmatched entries as
|
|
36
38
|
recorded but unresolved and do not pass them to native thread tools.
|
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
|
@@ -5,7 +5,10 @@ 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;
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const UUID_SOURCE = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
9
|
+
const UUID_PATTERN = new RegExp(`^${UUID_SOURCE}$`);
|
|
10
|
+
const TASKCHEF_MARKER_PATTERN = new RegExp(`^<!-- taskchef_id=(${UUID_SOURCE}) -->$`);
|
|
11
|
+
const LEGACY_TASKCHEF_MARKER_PATTERN = new RegExp(`^# taskchef_id=(${UUID_SOURCE})$`);
|
|
9
12
|
|
|
10
13
|
function requireObject(value, name) {
|
|
11
14
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -67,14 +70,19 @@ function wait(delayMs) {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
export function taskChefMarker(taskId) {
|
|
70
|
-
return
|
|
73
|
+
return `<!-- taskchef_id=${requireUuid(taskId)} -->`;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
export function parseTaskChefMarker(instruction) {
|
|
76
|
+
export function parseTaskChefMarker(instruction, { allowLegacyHeading = false } = {}) {
|
|
74
77
|
if (typeof instruction !== "string") return null;
|
|
75
78
|
const firstLine = instruction.split(/\r?\n/, 1)[0];
|
|
76
|
-
const
|
|
77
|
-
|
|
79
|
+
const currentMatch = firstLine.match(TASKCHEF_MARKER_PATTERN);
|
|
80
|
+
if (currentMatch !== null) {
|
|
81
|
+
const prefix = instruction.match(/^([^\r\n]*)(\r?\n)\2/);
|
|
82
|
+
return prefix === null ? null : currentMatch[1];
|
|
83
|
+
}
|
|
84
|
+
if (!allowLegacyHeading) return null;
|
|
85
|
+
return firstLine.match(LEGACY_TASKCHEF_MARKER_PATTERN)?.[1] ?? null;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
export function prepareDelegation(instruction, { taskId = randomUUID() } = {}) {
|
|
@@ -119,9 +127,9 @@ export function structuredDelegatedInputs(result) {
|
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
export function hasExactTaskChefMarker(result, taskId) {
|
|
122
|
-
const
|
|
130
|
+
const id = requireUuid(taskId);
|
|
123
131
|
return structuredDelegatedInputs(result).some(
|
|
124
|
-
(input) => input
|
|
132
|
+
(input) => parseTaskChefMarker(input) === id,
|
|
125
133
|
);
|
|
126
134
|
}
|
|
127
135
|
|
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 () => {
|
|
@@ -701,7 +739,11 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
701
739
|
});
|
|
702
740
|
}
|
|
703
741
|
|
|
704
|
-
async function validateDispatchShape(
|
|
742
|
+
async function validateDispatchShape(
|
|
743
|
+
dispatch,
|
|
744
|
+
name = "task",
|
|
745
|
+
{ allowLegacyHeading = false } = {},
|
|
746
|
+
) {
|
|
705
747
|
requireExactFields(dispatch, DISPATCH_FIELDS, name);
|
|
706
748
|
if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(dispatch.schemaVersion)) {
|
|
707
749
|
throw new Error(`unsupported ${name} schemaVersion`);
|
|
@@ -724,7 +766,7 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
724
766
|
};
|
|
725
767
|
if (
|
|
726
768
|
normalized.threadId === null &&
|
|
727
|
-
parseTaskChefMarker(normalized.instruction) !== normalized.id
|
|
769
|
+
parseTaskChefMarker(normalized.instruction, { allowLegacyHeading }) !== normalized.id
|
|
728
770
|
) {
|
|
729
771
|
throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
|
|
730
772
|
}
|
|
@@ -756,7 +798,9 @@ async function readDispatchRecordsUnlocked(root) {
|
|
|
756
798
|
records.push({
|
|
757
799
|
line,
|
|
758
800
|
raw: value,
|
|
759
|
-
normalized: await validateDispatchShape(value, `task line ${index + 1}
|
|
801
|
+
normalized: await validateDispatchShape(value, `task line ${index + 1}`, {
|
|
802
|
+
allowLegacyHeading: true,
|
|
803
|
+
}),
|
|
760
804
|
});
|
|
761
805
|
}
|
|
762
806
|
const ids = new Set();
|
|
@@ -823,13 +867,13 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
|
823
867
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
824
868
|
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
825
869
|
const dispatch = dispatches[index];
|
|
826
|
-
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
827
|
-
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
828
|
-
}
|
|
829
870
|
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
830
871
|
if (dispatch.threadId !== null) {
|
|
831
872
|
throw new Error(`task already has a different threadId: ${id}`);
|
|
832
873
|
}
|
|
874
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
875
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
876
|
+
}
|
|
833
877
|
if (dispatches.some((item) => item.threadId === durableThreadId)) {
|
|
834
878
|
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
835
879
|
}
|