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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.0.2",
3
+ "version": "5.1.1",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
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. loads and validates configured projects
182
- 2. selects one unambiguous target
183
- 3. generates a full UUID and prefixes the instruction with its exact
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 when creation returns a durable thread ID
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 near 10 and 30 seconds after the provisional result, filters
193
- candidates by available host/project/time/worktree metadata, uses title only
194
- as an advisory ordering hint, and accepts only one thread whose structured
195
- delegated input starts with the exact marker
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 time out unresolved; multiple
206
- exact matches are ambiguous. Snapshot, candidate-read, native-resolution, or
207
- task-resolution errors leave the already-recorded nullable entry intact. No
208
- snapshot, candidate read, marker verification, or task-resolution write starts
209
- after the 30-second deadline, so tool latency can reduce the number of attempts.
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
@@ -10,6 +10,7 @@ export {
10
10
  importProjects,
11
11
  initializeWorkspace,
12
12
  listProjects,
13
+ prepareDispatch,
13
14
  readConfig,
14
15
  listTasks,
15
16
  readTask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.0.2",
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. Run `<plugin-root>/bin/taskchef.js workspace path --json`, then run
31
- `<plugin-root>/bin/taskchef.js project list --json`
32
- to load and validate the configured routing targets. Use
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 native projects once and require the exact configured path.
49
- 5. Generate a lowercase full UUID task ID before creation. Prefix the complete
50
- executor instruction with exactly `<!-- taskchef_id=<full UUID> -->` as the
51
- first line, followed by a blank line and the instruction body. Preserve this
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
- Send exactly `id`, `project`, `title`, `instruction`, and `threadId` as JSON
60
- on stdin. Use the configured project path for `project`, and send the marked
61
- instruction unchanged. Never persist a provisional `clientThreadId` or
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, near 10 and 30 seconds after the provisional
76
- result. Count tool latency against the 30-second deadline. Do not start a
77
- snapshot, candidate read, marker verification, or task-resolution write
78
- after it.
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. Project import reads a JSON
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, 29_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, { verified = false } = {}) => {
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 (!hasResolutionTime()) return null;
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: hasResolutionTime,
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 (!hasResolutionTime()) return null;
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 checkpointMs = checkpointsMs[index];
476
- const elapsedBeforeAttempt = now() - resolutionStartedAt;
477
- if (elapsedBeforeAttempt > timeoutMs) break;
478
- const remainingDelayMs = checkpointMs - elapsedBeforeAttempt;
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
- if (!hasResolutionTime()) break;
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
- if (hasResolutionTime()) {
498
- candidates = filterThreadCandidates(snapshot, {
499
- excludedThreadIds: provisionalIds,
500
- hostId: expected.hostId ?? null,
501
- projectId: expected.projectId ?? target.projectId ?? null,
502
- title,
503
- createdAfter,
504
- environmentType: expected.environmentType ?? target.environment?.type ?? null,
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
- if (hasResolutionTime()) {
509
- attemptFailed = true;
510
- discoveryErrors.push({
511
- attempt,
512
- operation: "listThreads",
513
- message: error instanceof Error ? error.message : String(error),
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 { normalizeDurableThreadId, parseTaskChefMarker } from "./delegation.js";
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 withWorkspaceLock(workspaceRoot, operation) {
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
- const release = await lockfile.lock(workspaceRoot, {
163
- realpath: false,
164
- lockfilePath: lockPath,
165
- stale: 600_000,
166
- update: 10_000,
167
- retries: { retries: 70, factor: 1, minTimeout: 100, maxTimeout: 100 },
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 () => {