taskchef 3.0.2 → 4.0.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 +15 -0
- package/README.md +48 -16
- package/SPEC.md +111 -44
- package/assets/taskchef-dispatcher-instructions.md +2 -0
- package/index.js +24 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +29 -8
- package/skills/taskchef-delegate/SKILL.md +85 -14
- package/skills/taskchef-report/SKILL.md +20 -10
- package/src/cli.js +33 -5
- package/src/delegation.js +555 -0
- package/src/github.js +108 -0
- package/src/workspace.js +152 -240
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: taskchef-report
|
|
3
|
-
description: "Report the live state of Codex tasks recorded in a TaskChef task history. Use only when the user asks for status, outcomes, or a report about delegated work. Queries each relevant task once, never polls or waits, and never persists status or results."
|
|
3
|
+
description: "Report the live state of Codex tasks recorded in a TaskChef task history. Use only when the user asks for status, outcomes, or a report about delegated work. Queries each relevant task once, may resolve a nullable thread ID from one exact marker match, never polls or waits, and never persists status or results."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# TaskChef Report
|
|
@@ -23,16 +23,26 @@ all deterministic task-log operations.
|
|
|
23
23
|
`<plugin-root>/bin/taskchef.js task list --json --workspace <workspace>`
|
|
24
24
|
once, then select matching entries. Ask the user if the match is ambiguous.
|
|
25
25
|
- Use the full list only when the user asks for an overview of the task history.
|
|
26
|
-
2.
|
|
27
|
-
with
|
|
28
|
-
|
|
26
|
+
2. Separate entries whose `threadId` is `null`. For those entries, take one
|
|
27
|
+
`list_threads` snapshot with limit 50, filter by available project metadata,
|
|
28
|
+
and inspect candidate structured delegated inputs. Use title only to
|
|
29
|
+
prioritize candidates, never to exclude them. When exactly one candidate
|
|
30
|
+
starts with the task's exact marker, run
|
|
31
|
+
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json --workspace <workspace>`.
|
|
32
|
+
Do not resolve zero or multiple matches. Report unmatched entries as
|
|
33
|
+
recorded but unresolved and do not pass them to native thread tools.
|
|
34
|
+
3. Query every resolved or previously durable thread exactly once using
|
|
35
|
+
immediate native snapshots, with no more than eight targets per call.
|
|
36
|
+
4. Summarize the live state and any reported outcome for each requested task.
|
|
29
37
|
Distinguish active work, requests for user input, completed work, and failed
|
|
30
38
|
or partial attempts.
|
|
31
|
-
|
|
32
|
-
TaskChef
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
5. Treat each Codex task as the source of truth. The task log records what
|
|
40
|
+
TaskChef submitted, but it does not contain the task's current state.
|
|
41
|
+
6. Never edit `tasks.jsonl` directly. Use `task resolve` only for one exact
|
|
42
|
+
marker match. Never persist status, results, transcripts, or hidden
|
|
43
|
+
reasoning. Do not poll or wait for future activity.
|
|
35
44
|
|
|
36
45
|
If the task history is empty, say that TaskChef has not recorded any tasks. If
|
|
37
|
-
a
|
|
38
|
-
|
|
46
|
+
a task has no durable thread ID, identify it by task ID and say that its marker
|
|
47
|
+
remains available for later recovery. If a recorded thread cannot be read,
|
|
48
|
+
identify it by task ID and thread ID, then continue with the remaining entries.
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readTask,
|
|
13
13
|
recordTask,
|
|
14
14
|
removeProject,
|
|
15
|
+
resolveTask,
|
|
15
16
|
} from "./workspace.js";
|
|
16
17
|
|
|
17
18
|
async function readStdin() {
|
|
@@ -36,6 +37,14 @@ function option(args, name, fallback) {
|
|
|
36
37
|
return args[index + 1];
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function options(args, name) {
|
|
41
|
+
const values = [];
|
|
42
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
43
|
+
if (args[index] === name) values.push(args[index + 1]);
|
|
44
|
+
}
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
function validateCommandArgs(
|
|
40
49
|
args,
|
|
41
50
|
startIndex,
|
|
@@ -92,7 +101,6 @@ async function initialize(args) {
|
|
|
92
101
|
`Workspace: ${value.workspace}`,
|
|
93
102
|
`Configuration: ${value.config.action}`,
|
|
94
103
|
`Task log: ${value.tasks.action}`,
|
|
95
|
-
`Legacy tasks: ${value.legacyTasks.action}`,
|
|
96
104
|
`Instructions: ${value.instructions.action}`,
|
|
97
105
|
`Legacy skill links removed: ${value.legacySkills.removed.length}`,
|
|
98
106
|
].join("\n"));
|
|
@@ -113,6 +121,7 @@ async function projectAdd(args) {
|
|
|
113
121
|
validateCommandArgs(args, 3, {
|
|
114
122
|
values: ["--workspace", "--name", "--description", "--github-repo"],
|
|
115
123
|
switches: ["--json", "--no-github"],
|
|
124
|
+
repeatable: ["--github-repo"],
|
|
116
125
|
});
|
|
117
126
|
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
118
127
|
throw new Error("--no-github and --github-repo cannot be used together");
|
|
@@ -122,8 +131,8 @@ async function projectAdd(args) {
|
|
|
122
131
|
const description = option(args, "--description", null);
|
|
123
132
|
if (name !== null) input.name = name;
|
|
124
133
|
if (description !== null) input.description = description;
|
|
125
|
-
if (args.includes("--no-github")) input.
|
|
126
|
-
else if (args.includes("--github-repo")) input.
|
|
134
|
+
if (args.includes("--no-github")) input.githubRepos = [];
|
|
135
|
+
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
127
136
|
const project = await addProject(workspaceRoot(args), input);
|
|
128
137
|
print(project, args, (value) => `Added ${value.name}: ${value.path}`);
|
|
129
138
|
return 0;
|
|
@@ -152,10 +161,11 @@ async function projectList(args) {
|
|
|
152
161
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
153
162
|
const projects = await listProjects(workspaceRoot(args));
|
|
154
163
|
print({ projectCount: projects.length, projects }, args, (value) => table(
|
|
155
|
-
["NAME", "KIND", "PATH"],
|
|
164
|
+
["NAME", "KIND", "GITHUB REPOSITORIES", "PATH"],
|
|
156
165
|
value.projects.map((project) => [
|
|
157
166
|
project.name,
|
|
158
167
|
project.isGitRepository ? "git" : "folder",
|
|
168
|
+
project.githubRepos.join(", ") || "-",
|
|
159
169
|
project.path,
|
|
160
170
|
]),
|
|
161
171
|
));
|
|
@@ -180,6 +190,22 @@ async function taskRecord(args) {
|
|
|
180
190
|
return 0;
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
async function taskResolve(args) {
|
|
194
|
+
if (!args[2] || args[2].startsWith("--")) throw new Error("task resolve requires a task ID");
|
|
195
|
+
validateCommandArgs(args, 3, {
|
|
196
|
+
values: ["--thread-id", "--workspace"],
|
|
197
|
+
switches: ["--json"],
|
|
198
|
+
});
|
|
199
|
+
if (!args.includes("--thread-id")) throw new Error("task resolve requires --thread-id");
|
|
200
|
+
const task = await resolveTask(
|
|
201
|
+
workspaceRoot(args),
|
|
202
|
+
args[2],
|
|
203
|
+
option(args, "--thread-id"),
|
|
204
|
+
);
|
|
205
|
+
print(task, args, (value) => `Resolved ${value.id}: ${value.threadId}`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
183
209
|
async function taskShow(args) {
|
|
184
210
|
validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
|
|
185
211
|
print(await readTask(workspaceRoot(args), args[2]), args);
|
|
@@ -224,11 +250,12 @@ Usage:
|
|
|
224
250
|
taskchef help
|
|
225
251
|
taskchef doctor [--json] [--workspace <path>]
|
|
226
252
|
taskchef workspace init [--json] [--workspace <path>]
|
|
227
|
-
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> | --no-github] [--json] [--workspace <path>]
|
|
253
|
+
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
228
254
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
229
255
|
taskchef project list [--json] [--workspace <path>]
|
|
230
256
|
taskchef project remove <name> [--json] [--workspace <path>]
|
|
231
257
|
taskchef task record [--json] [--workspace <path>]
|
|
258
|
+
taskchef task resolve <task-id> --thread-id <thread-id> [--json] [--workspace <path>]
|
|
232
259
|
taskchef task show <task-id> [--json] [--workspace <path>]
|
|
233
260
|
taskchef task list [--project <name-or-path>] [--json] [--workspace <path>]
|
|
234
261
|
taskchef task summary [--json] [--workspace <path>]
|
|
@@ -250,6 +277,7 @@ export async function runCli(args) {
|
|
|
250
277
|
if (args[0] === "project" && args[1] === "list") return projectList(args);
|
|
251
278
|
if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
|
|
252
279
|
if (args[0] === "task" && args[1] === "record") return taskRecord(args);
|
|
280
|
+
if (args[0] === "task" && args[1] === "resolve") return taskResolve(args);
|
|
253
281
|
if (args[0] === "task" && args[1] === "show" && args[2]) return taskShow(args);
|
|
254
282
|
if (args[0] === "task" && args[1] === "list") return taskList(args);
|
|
255
283
|
if (args[0] === "task" && args[1] === "summary") return taskSummary(args);
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const THREAD_RESOLUTION_CHECKPOINTS_MS = Object.freeze([10_000, 29_000]);
|
|
4
|
+
export const THREAD_RESOLUTION_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const THREAD_RESOLUTION_RECENT_LIMIT = 50;
|
|
6
|
+
export const THREAD_RESOLUTION_CLOCK_SKEW_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
9
|
+
|
|
10
|
+
function requireObject(value, name) {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
12
|
+
throw new Error(`${name} must be an object`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireString(value, name) {
|
|
18
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
19
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireUuid(value, name = "taskId") {
|
|
25
|
+
requireString(value, name);
|
|
26
|
+
if (!UUID_PATTERN.test(value)) throw new Error(`${name} must be a lowercase full UUID`);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseToolResult(value, name) {
|
|
31
|
+
if (typeof value !== "string") return requireObject(value, name);
|
|
32
|
+
try {
|
|
33
|
+
return requireObject(JSON.parse(value), name);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof SyntaxError) throw new Error(`${name} must contain valid JSON`);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function timestampMilliseconds(value) {
|
|
41
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
42
|
+
return value < 1_000_000_000_000 ? value * 1_000 : value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toolIdentifier(value) {
|
|
46
|
+
if (typeof value !== "string") return null;
|
|
47
|
+
const normalized = value.trim();
|
|
48
|
+
return normalized.length > 0 ? normalized : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isProvisionalThreadId(value) {
|
|
52
|
+
const id = toolIdentifier(value);
|
|
53
|
+
return id !== null && id.startsWith("local:");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normalizeDurableThreadId(value, name = "threadId") {
|
|
57
|
+
const id = toolIdentifier(value);
|
|
58
|
+
if (id === null) throw new Error(`${name} must be a non-empty string`);
|
|
59
|
+
if (isProvisionalThreadId(id)) {
|
|
60
|
+
throw new Error(`${name} must be a durable thread ID, not a provisional local ID`);
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function wait(delayMs) {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function taskChefMarker(taskId) {
|
|
70
|
+
return `# taskchef_id=${requireUuid(taskId)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseTaskChefMarker(instruction) {
|
|
74
|
+
if (typeof instruction !== "string") return null;
|
|
75
|
+
const firstLine = instruction.split(/\r?\n/, 1)[0];
|
|
76
|
+
const match = firstLine.match(/^# taskchef_id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/);
|
|
77
|
+
return match?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function prepareDelegation(instruction, { taskId = randomUUID() } = {}) {
|
|
81
|
+
requireString(instruction, "instruction");
|
|
82
|
+
if (parseTaskChefMarker(instruction) !== null) {
|
|
83
|
+
throw new Error("instruction already contains a TaskChef marker");
|
|
84
|
+
}
|
|
85
|
+
const id = requireUuid(taskId);
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
instruction: `${taskChefMarker(id)}\n\n${instruction}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function listThreadEntries(result) {
|
|
93
|
+
const parsed = parseToolResult(result, "list_threads result");
|
|
94
|
+
const entries = [...(parsed.pinnedThreads ?? []), ...(parsed.threads ?? [])];
|
|
95
|
+
const unique = new Map();
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const id = toolIdentifier(entry?.id);
|
|
98
|
+
if (id !== null && !unique.has(id)) {
|
|
99
|
+
unique.set(id, { ...entry, id });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...unique.values()];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function structuredDelegatedInputs(result) {
|
|
106
|
+
const parsed = parseToolResult(result, "read_thread result");
|
|
107
|
+
const inputs = [];
|
|
108
|
+
for (const turn of parsed.turns ?? []) {
|
|
109
|
+
for (const item of turn?.items ?? []) {
|
|
110
|
+
if (item?.type !== "userMessage") continue;
|
|
111
|
+
for (const part of item.content ?? []) {
|
|
112
|
+
if (typeof part?.codexDelegation?.input === "string") {
|
|
113
|
+
inputs.push(part.codexDelegation.input);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return inputs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function hasExactTaskChefMarker(result, taskId) {
|
|
122
|
+
const marker = taskChefMarker(taskId);
|
|
123
|
+
return structuredDelegatedInputs(result).some(
|
|
124
|
+
(input) => input.split(/\r?\n/, 1)[0] === marker,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function filterThreadCandidates(result, {
|
|
129
|
+
baselineThreadIds = new Set(),
|
|
130
|
+
excludedThreadIds = new Set(),
|
|
131
|
+
hostId = null,
|
|
132
|
+
projectId = null,
|
|
133
|
+
title = null,
|
|
134
|
+
createdAfter = null,
|
|
135
|
+
environmentType = null,
|
|
136
|
+
} = {}) {
|
|
137
|
+
const minimumCreatedAt = createdAfter === null
|
|
138
|
+
? null
|
|
139
|
+
: timestampMilliseconds(createdAfter) - THREAD_RESOLUTION_CLOCK_SKEW_MS;
|
|
140
|
+
const candidates = listThreadEntries(result).filter((thread) => {
|
|
141
|
+
if (
|
|
142
|
+
baselineThreadIds.has(thread.id)
|
|
143
|
+
|| excludedThreadIds.has(thread.id)
|
|
144
|
+
|| isProvisionalThreadId(thread.id)
|
|
145
|
+
|| thread.kind !== "codex"
|
|
146
|
+
) return false;
|
|
147
|
+
if (hostId !== null && thread.hostId !== undefined && thread.hostId !== hostId) return false;
|
|
148
|
+
if (projectId !== null && thread.projectId !== undefined && thread.projectId !== projectId) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const candidateEnvironmentType = thread.environment?.type
|
|
152
|
+
?? thread.environmentType
|
|
153
|
+
?? thread.backing?.environment?.type;
|
|
154
|
+
if (
|
|
155
|
+
environmentType !== null &&
|
|
156
|
+
candidateEnvironmentType !== undefined &&
|
|
157
|
+
candidateEnvironmentType !== environmentType
|
|
158
|
+
) return false;
|
|
159
|
+
const candidateTime = timestampMilliseconds(thread.createdAt ?? thread.updatedAt);
|
|
160
|
+
if (minimumCreatedAt !== null && candidateTime !== null && candidateTime < minimumCreatedAt) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
if (title === null) return candidates;
|
|
166
|
+
return candidates.sort((left, right) =>
|
|
167
|
+
Number(right.title === title) - Number(left.title === title));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateResolutionSchedule(checkpointsMs, timeoutMs) {
|
|
171
|
+
if (!Array.isArray(checkpointsMs) || checkpointsMs.length === 0) {
|
|
172
|
+
throw new Error("checkpointsMs must contain at least one checkpoint");
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
|
|
175
|
+
throw new Error("timeoutMs must be positive");
|
|
176
|
+
}
|
|
177
|
+
let previous = 0;
|
|
178
|
+
for (const checkpoint of checkpointsMs) {
|
|
179
|
+
if (!Number.isInteger(checkpoint) || checkpoint <= previous || checkpoint > timeoutMs) {
|
|
180
|
+
throw new Error("checkpointsMs must be strictly increasing positive integers within timeoutMs");
|
|
181
|
+
}
|
|
182
|
+
previous = checkpoint;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function inspectCandidates(candidates, {
|
|
187
|
+
readThread,
|
|
188
|
+
taskId,
|
|
189
|
+
attempt,
|
|
190
|
+
canVerify = () => true,
|
|
191
|
+
}) {
|
|
192
|
+
const inspected = await Promise.all(candidates.map(async (candidate) => {
|
|
193
|
+
try {
|
|
194
|
+
const readResult = await readThread({
|
|
195
|
+
threadId: candidate.id,
|
|
196
|
+
...(candidate.hostId ? { hostId: candidate.hostId } : {}),
|
|
197
|
+
turnLimit: 1,
|
|
198
|
+
includeOutputs: false,
|
|
199
|
+
});
|
|
200
|
+
if (!canVerify()) {
|
|
201
|
+
return { candidate, matches: false, error: null };
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
candidate,
|
|
205
|
+
matches: hasExactTaskChefMarker(readResult, taskId),
|
|
206
|
+
error: null,
|
|
207
|
+
};
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return {
|
|
210
|
+
candidate,
|
|
211
|
+
matches: false,
|
|
212
|
+
error: {
|
|
213
|
+
attempt,
|
|
214
|
+
operation: "readThread",
|
|
215
|
+
threadId: candidate.id,
|
|
216
|
+
message: error instanceof Error ? error.message : String(error),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}));
|
|
221
|
+
return {
|
|
222
|
+
exactMatches: inspected.filter((item) => item.matches).map((item) => item.candidate),
|
|
223
|
+
errors: inspected.flatMap((item) => item.error === null ? [] : [item.error]),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function createAndRecordDelegation({
|
|
228
|
+
project,
|
|
229
|
+
title,
|
|
230
|
+
instruction,
|
|
231
|
+
target,
|
|
232
|
+
expected = {},
|
|
233
|
+
createThread,
|
|
234
|
+
listThreads,
|
|
235
|
+
readThread,
|
|
236
|
+
recordTask,
|
|
237
|
+
resolveRecordedTask = null,
|
|
238
|
+
resolveProvisionalThread = null,
|
|
239
|
+
taskId = randomUUID(),
|
|
240
|
+
checkpointsMs = THREAD_RESOLUTION_CHECKPOINTS_MS,
|
|
241
|
+
timeoutMs = THREAD_RESOLUTION_TIMEOUT_MS,
|
|
242
|
+
recentLimit = THREAD_RESOLUTION_RECENT_LIMIT,
|
|
243
|
+
now = Date.now,
|
|
244
|
+
waitImpl = wait,
|
|
245
|
+
}) {
|
|
246
|
+
requireString(project, "project");
|
|
247
|
+
requireString(title, "title");
|
|
248
|
+
requireObject(target, "target");
|
|
249
|
+
for (const [value, name] of [
|
|
250
|
+
[createThread, "createThread"],
|
|
251
|
+
[listThreads, "listThreads"],
|
|
252
|
+
[readThread, "readThread"],
|
|
253
|
+
[recordTask, "recordTask"],
|
|
254
|
+
]) {
|
|
255
|
+
if (typeof value !== "function") throw new Error(`${name} must be a function`);
|
|
256
|
+
}
|
|
257
|
+
if (resolveProvisionalThread !== null && typeof resolveProvisionalThread !== "function") {
|
|
258
|
+
throw new Error("resolveProvisionalThread must be a function or null");
|
|
259
|
+
}
|
|
260
|
+
if (resolveRecordedTask !== null && typeof resolveRecordedTask !== "function") {
|
|
261
|
+
throw new Error("resolveRecordedTask must be a function or null");
|
|
262
|
+
}
|
|
263
|
+
validateResolutionSchedule(checkpointsMs, timeoutMs);
|
|
264
|
+
|
|
265
|
+
const prepared = prepareDelegation(instruction, { taskId });
|
|
266
|
+
const discoveryErrors = [];
|
|
267
|
+
const createdAfter = now();
|
|
268
|
+
const createResult = parseToolResult(await createThread({
|
|
269
|
+
prompt: prepared.instruction,
|
|
270
|
+
title,
|
|
271
|
+
target,
|
|
272
|
+
}), "create_thread result");
|
|
273
|
+
const clientThreadId = toolIdentifier(createResult.clientThreadId);
|
|
274
|
+
const pendingWorktreeId = toolIdentifier(createResult.pendingWorktreeId);
|
|
275
|
+
const returnedThreadId = toolIdentifier(createResult.threadId);
|
|
276
|
+
const returnedProvisionalId = isProvisionalThreadId(returnedThreadId)
|
|
277
|
+
? returnedThreadId
|
|
278
|
+
: null;
|
|
279
|
+
const provisional = clientThreadId ?? pendingWorktreeId ?? returnedProvisionalId;
|
|
280
|
+
const resolutionStartedAt = provisional === null ? null : now();
|
|
281
|
+
const provisionalIds = new Set([
|
|
282
|
+
clientThreadId,
|
|
283
|
+
pendingWorktreeId,
|
|
284
|
+
returnedProvisionalId,
|
|
285
|
+
].filter(Boolean));
|
|
286
|
+
const durableThreadId = !isProvisionalThreadId(returnedThreadId)
|
|
287
|
+
&& !provisionalIds.has(returnedThreadId)
|
|
288
|
+
? returnedThreadId
|
|
289
|
+
: null;
|
|
290
|
+
|
|
291
|
+
if (durableThreadId !== null) {
|
|
292
|
+
await recordTask({
|
|
293
|
+
id: prepared.id,
|
|
294
|
+
project,
|
|
295
|
+
title,
|
|
296
|
+
instruction: prepared.instruction,
|
|
297
|
+
threadId: durableThreadId,
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
status: "recorded",
|
|
301
|
+
resolution: "immediate",
|
|
302
|
+
...prepared,
|
|
303
|
+
threadId: durableThreadId,
|
|
304
|
+
hostId: createResult.hostId ?? expected.hostId ?? null,
|
|
305
|
+
provisional,
|
|
306
|
+
attempts: 0,
|
|
307
|
+
discoveryErrors,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (provisional === null) {
|
|
312
|
+
await recordTask({
|
|
313
|
+
id: prepared.id,
|
|
314
|
+
project,
|
|
315
|
+
title,
|
|
316
|
+
instruction: prepared.instruction,
|
|
317
|
+
threadId: null,
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
status: "recorded-unresolved",
|
|
321
|
+
reason: "create-returned-no-thread-identifier",
|
|
322
|
+
...prepared,
|
|
323
|
+
threadId: null,
|
|
324
|
+
provisional: null,
|
|
325
|
+
attempts: 0,
|
|
326
|
+
matchingThreadIds: [],
|
|
327
|
+
discoveryErrors,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await recordTask({
|
|
332
|
+
id: prepared.id,
|
|
333
|
+
project,
|
|
334
|
+
title,
|
|
335
|
+
instruction: prepared.instruction,
|
|
336
|
+
threadId: null,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (resolveRecordedTask === null) {
|
|
340
|
+
return {
|
|
341
|
+
status: "recorded-unresolved",
|
|
342
|
+
reason: "task-resolution-unavailable",
|
|
343
|
+
...prepared,
|
|
344
|
+
threadId: null,
|
|
345
|
+
provisional,
|
|
346
|
+
attempts: 0,
|
|
347
|
+
matchingThreadIds: [],
|
|
348
|
+
discoveryErrors,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const resolutionDeadline = resolutionStartedAt + timeoutMs;
|
|
353
|
+
let deadlineExpired = false;
|
|
354
|
+
const hasResolutionTime = () => {
|
|
355
|
+
if (now() < resolutionDeadline) return true;
|
|
356
|
+
deadlineExpired = true;
|
|
357
|
+
return false;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const acceptResolvedThread = async (thread, resolution, attempt, { verified = false } = {}) => {
|
|
361
|
+
const threadId = toolIdentifier(thread.id);
|
|
362
|
+
if (
|
|
363
|
+
threadId === null
|
|
364
|
+
|| provisionalIds.has(threadId)
|
|
365
|
+
|| isProvisionalThreadId(threadId)
|
|
366
|
+
) {
|
|
367
|
+
discoveryErrors.push({
|
|
368
|
+
attempt,
|
|
369
|
+
operation: "validateThreadId",
|
|
370
|
+
...(threadId === null ? {} : { threadId }),
|
|
371
|
+
message: "resolved threadId is not a durable identifier",
|
|
372
|
+
});
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const durableThread = { ...thread, id: threadId };
|
|
376
|
+
if (!hasResolutionTime()) return null;
|
|
377
|
+
if (!verified) {
|
|
378
|
+
const inspected = await inspectCandidates([durableThread], {
|
|
379
|
+
readThread,
|
|
380
|
+
taskId: prepared.id,
|
|
381
|
+
attempt,
|
|
382
|
+
canVerify: hasResolutionTime,
|
|
383
|
+
});
|
|
384
|
+
discoveryErrors.push(...inspected.errors);
|
|
385
|
+
if (inspected.exactMatches.length !== 1 || inspected.errors.length > 0) return null;
|
|
386
|
+
}
|
|
387
|
+
if (!hasResolutionTime()) return null;
|
|
388
|
+
try {
|
|
389
|
+
await resolveRecordedTask({ id: prepared.id, threadId });
|
|
390
|
+
} catch (error) {
|
|
391
|
+
discoveryErrors.push({
|
|
392
|
+
attempt,
|
|
393
|
+
operation: "resolveRecordedTask",
|
|
394
|
+
threadId,
|
|
395
|
+
message: error instanceof Error ? error.message : String(error),
|
|
396
|
+
});
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
status: "recorded",
|
|
401
|
+
resolution,
|
|
402
|
+
...prepared,
|
|
403
|
+
threadId,
|
|
404
|
+
hostId: durableThread.hostId ?? expected.hostId ?? null,
|
|
405
|
+
provisional,
|
|
406
|
+
attempts: attempt,
|
|
407
|
+
discoveryErrors,
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
if (resolveProvisionalThread !== null) {
|
|
412
|
+
let nativeResult = null;
|
|
413
|
+
let nativeAttempts = 0;
|
|
414
|
+
const remainingTimeoutMs = timeoutMs - (now() - resolutionStartedAt);
|
|
415
|
+
if (remainingTimeoutMs > 0) {
|
|
416
|
+
nativeAttempts = 1;
|
|
417
|
+
try {
|
|
418
|
+
const nativeResponse = await resolveProvisionalThread({
|
|
419
|
+
provisionalId: provisional,
|
|
420
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
421
|
+
...(pendingWorktreeId ? { pendingWorktreeId } : {}),
|
|
422
|
+
timeoutMs: remainingTimeoutMs,
|
|
423
|
+
});
|
|
424
|
+
if (hasResolutionTime()) {
|
|
425
|
+
nativeResult = parseToolResult(nativeResponse, "provisional thread resolver result");
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (hasResolutionTime()) {
|
|
429
|
+
discoveryErrors.push({
|
|
430
|
+
attempt: 1,
|
|
431
|
+
operation: "resolveProvisionalThread",
|
|
432
|
+
message: error instanceof Error ? error.message : String(error),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
hasResolutionTime();
|
|
438
|
+
const nativeThreadId = toolIdentifier(nativeResult?.threadId);
|
|
439
|
+
if (nativeThreadId !== null) {
|
|
440
|
+
const resolved = await acceptResolvedThread({
|
|
441
|
+
id: nativeThreadId,
|
|
442
|
+
hostId: nativeResult.hostId ?? expected.hostId ?? null,
|
|
443
|
+
}, "native", 1);
|
|
444
|
+
if (resolved !== null) return resolved;
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
status: "recorded-unresolved",
|
|
448
|
+
reason: discoveryErrors.length > 0
|
|
449
|
+
? "thread-discovery-error"
|
|
450
|
+
: deadlineExpired
|
|
451
|
+
? "resolution-deadline-exhausted"
|
|
452
|
+
: "native-resolution-unresolved",
|
|
453
|
+
...prepared,
|
|
454
|
+
threadId: null,
|
|
455
|
+
provisional,
|
|
456
|
+
attempts: nativeAttempts,
|
|
457
|
+
matchingThreadIds: [],
|
|
458
|
+
discoveryErrors,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let exactMatches = [];
|
|
463
|
+
let ambiguousMatches = [];
|
|
464
|
+
let attemptsMade = 0;
|
|
465
|
+
for (let index = 0; index < checkpointsMs.length; index += 1) {
|
|
466
|
+
const attempt = index + 1;
|
|
467
|
+
const checkpointMs = checkpointsMs[index];
|
|
468
|
+
const elapsedBeforeAttempt = now() - resolutionStartedAt;
|
|
469
|
+
if (elapsedBeforeAttempt > timeoutMs) break;
|
|
470
|
+
const remainingDelayMs = checkpointMs - elapsedBeforeAttempt;
|
|
471
|
+
if (remainingDelayMs > 0) {
|
|
472
|
+
try {
|
|
473
|
+
await waitImpl(remainingDelayMs);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
discoveryErrors.push({
|
|
476
|
+
attempt,
|
|
477
|
+
operation: "wait",
|
|
478
|
+
message: error instanceof Error ? error.message : String(error),
|
|
479
|
+
});
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!hasResolutionTime()) break;
|
|
484
|
+
attemptsMade = attempt;
|
|
485
|
+
let candidates = [];
|
|
486
|
+
let attemptFailed = false;
|
|
487
|
+
try {
|
|
488
|
+
const snapshot = await listThreads({ limit: recentLimit });
|
|
489
|
+
if (hasResolutionTime()) {
|
|
490
|
+
candidates = filterThreadCandidates(snapshot, {
|
|
491
|
+
excludedThreadIds: provisionalIds,
|
|
492
|
+
hostId: expected.hostId ?? null,
|
|
493
|
+
projectId: expected.projectId ?? target.projectId ?? null,
|
|
494
|
+
title,
|
|
495
|
+
createdAfter,
|
|
496
|
+
environmentType: expected.environmentType ?? target.environment?.type ?? null,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (hasResolutionTime()) {
|
|
501
|
+
attemptFailed = true;
|
|
502
|
+
discoveryErrors.push({
|
|
503
|
+
attempt,
|
|
504
|
+
operation: "listThreads",
|
|
505
|
+
message: error instanceof Error ? error.message : String(error),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (!hasResolutionTime()) break;
|
|
510
|
+
const inspected = await inspectCandidates(candidates, {
|
|
511
|
+
readThread,
|
|
512
|
+
taskId: prepared.id,
|
|
513
|
+
attempt,
|
|
514
|
+
canVerify: hasResolutionTime,
|
|
515
|
+
});
|
|
516
|
+
exactMatches = inspected.exactMatches;
|
|
517
|
+
discoveryErrors.push(...inspected.errors);
|
|
518
|
+
if (inspected.errors.length > 0) attemptFailed = true;
|
|
519
|
+
if (!hasResolutionTime()) break;
|
|
520
|
+
if (exactMatches.length > 1) ambiguousMatches = exactMatches;
|
|
521
|
+
if (
|
|
522
|
+
exactMatches.length === 1
|
|
523
|
+
&& ambiguousMatches.length === 0
|
|
524
|
+
&& !attemptFailed
|
|
525
|
+
&& discoveryErrors.length === 0
|
|
526
|
+
) {
|
|
527
|
+
const resolved = await acceptResolvedThread(
|
|
528
|
+
exactMatches[0],
|
|
529
|
+
"discovered",
|
|
530
|
+
attempt,
|
|
531
|
+
{ verified: true },
|
|
532
|
+
);
|
|
533
|
+
if (resolved !== null) return resolved;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const reason = ambiguousMatches.length > 1
|
|
538
|
+
? "multiple-exact-marker-matches"
|
|
539
|
+
: discoveryErrors.length > 0
|
|
540
|
+
? "thread-discovery-error"
|
|
541
|
+
: deadlineExpired
|
|
542
|
+
? "resolution-deadline-exhausted"
|
|
543
|
+
: "no-exact-marker-match";
|
|
544
|
+
return {
|
|
545
|
+
status: "recorded-unresolved",
|
|
546
|
+
reason,
|
|
547
|
+
...prepared,
|
|
548
|
+
threadId: null,
|
|
549
|
+
provisional,
|
|
550
|
+
attempts: attemptsMade,
|
|
551
|
+
discoveryErrors,
|
|
552
|
+
matchingThreadIds: (ambiguousMatches.length > 1 ? ambiguousMatches : exactMatches)
|
|
553
|
+
.map((thread) => thread.id),
|
|
554
|
+
};
|
|
555
|
+
}
|