taskchef 7.8.0 → 7.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +17 -17
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +36 -35
- package/docs/workflows.md +23 -22
- package/package.json +1 -1
- package/skills/taskchef-delegate/SKILL.md +3 -2
- package/skills/taskchef-executor/SKILL.md +17 -14
- package/skills/taskchef-report/SKILL.md +8 -9
- package/src/cli.js +4 -4
- package/src/dashboard/app.js +44 -20
- package/src/dashboard/index.html +1 -1
- package/src/dashboard/state.js +31 -0
- package/src/dashboard/styles.css +3 -1
- package/src/dashboard.js +12 -1
- package/src/delegation.js +11 -7
- package/src/mcp.js +23 -2
- package/src/workspace.js +227 -50
package/src/dashboard/state.js
CHANGED
|
@@ -31,6 +31,37 @@ export function taskStatusLabel(task) {
|
|
|
31
31
|
return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function latestTurnPresentation(task) {
|
|
35
|
+
const turn = task.latestTurn ?? null;
|
|
36
|
+
const result = turn?.result ?? null;
|
|
37
|
+
const requestSummary = turn?.requestSummary
|
|
38
|
+
?? (turn ? "Request not recorded by this TaskChef version." : task.title);
|
|
39
|
+
return {
|
|
40
|
+
turnId: turn?.turnId ?? task.turnId ?? null,
|
|
41
|
+
startedAt: turn?.startedAt ?? task.updatedAt ?? task.createdAt ?? null,
|
|
42
|
+
requestSummary,
|
|
43
|
+
resultStatus: result?.status ?? (task.status === "working" ? "working" : task.status),
|
|
44
|
+
resultSummary: result?.summary
|
|
45
|
+
?? (task.status === "working"
|
|
46
|
+
? "In progress"
|
|
47
|
+
: task.lastResult?.summary ?? task.summary ?? "No result reported."),
|
|
48
|
+
resultUpdatedAt: result?.updatedAt ?? null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeProjectedTurns(task, preservedTurns = []) {
|
|
53
|
+
if (Array.isArray(task.turns)) return task.turns;
|
|
54
|
+
if (!task.latestTurn) return preservedTurns;
|
|
55
|
+
const turns = [...preservedTurns];
|
|
56
|
+
const lastIndex = turns.length - 1;
|
|
57
|
+
if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
|
|
58
|
+
turns[lastIndex] = task.latestTurn;
|
|
59
|
+
} else {
|
|
60
|
+
turns.push(task.latestTurn);
|
|
61
|
+
}
|
|
62
|
+
return turns;
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
export function notificationTitle(notification) {
|
|
35
66
|
return NOTIFICATION_TITLES.get(notification.event) ?? "Task updated";
|
|
36
67
|
}
|
package/src/dashboard/styles.css
CHANGED
|
@@ -79,7 +79,8 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
|
|
|
79
79
|
.task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
80
80
|
.task-project { margin: 3px 0 12px; font-size: 0.86rem; }
|
|
81
81
|
.task-title, .task-summary { overflow-wrap: anywhere; }
|
|
82
|
-
.task-summary { max-width: 78ch; margin-bottom: 14px; }
|
|
82
|
+
.task-summary { display: grid; max-width: 78ch; margin-bottom: 14px; gap: 3px; }
|
|
83
|
+
.task-summary strong { margin-top: 5px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
83
84
|
time, .timestamp-missing { font-size: 0.78rem; }
|
|
84
85
|
.timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
|
|
85
86
|
.timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
@@ -133,6 +134,7 @@ dialog section + section { margin-top: 24px; }
|
|
|
133
134
|
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
134
135
|
.result-history-item p { margin-bottom: 7px; }
|
|
135
136
|
.result-history-item p:last-child { margin-bottom: 0; }
|
|
137
|
+
.result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
136
138
|
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
137
139
|
.result-history-empty { margin: 0; }
|
|
138
140
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
package/src/dashboard.js
CHANGED
|
@@ -139,6 +139,17 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
139
139
|
boundedText(task.turnId, 512, `${name} turn ID`);
|
|
140
140
|
boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
|
|
141
141
|
boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
|
|
142
|
+
boundedText(task.latestTurn?.requestSummary, 1_000, `${name} latest request summary`);
|
|
143
|
+
boundedText(task.latestTurn?.turnId, 512, `${name} latest turn ID`);
|
|
144
|
+
const turns = task.turns ?? [];
|
|
145
|
+
if (turns.length > 10_000) {
|
|
146
|
+
throw new Error(`${name} has too many turns for the dashboard`);
|
|
147
|
+
}
|
|
148
|
+
for (const [turnIndex, turn] of turns.entries()) {
|
|
149
|
+
boundedText(turn.requestSummary, 1_000, `${name} turn ${turnIndex + 1} request summary`);
|
|
150
|
+
boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
|
|
151
|
+
boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
|
|
152
|
+
}
|
|
142
153
|
const results = task.results ?? [];
|
|
143
154
|
if (results.length > 10_000) {
|
|
144
155
|
throw new Error(`${name} has too many results for the dashboard`);
|
|
@@ -160,7 +171,7 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
function taskListProjection(task) {
|
|
163
|
-
const { results: _results, ...projection } = task;
|
|
174
|
+
const { turns: _turns, results: _results, ...projection } = task;
|
|
164
175
|
return projection;
|
|
165
176
|
}
|
|
166
177
|
|
package/src/delegation.js
CHANGED
|
@@ -5,7 +5,7 @@ export const EXECUTOR_OWNERSHIP_PARAGRAPH = "This task owns the delegated assign
|
|
|
5
5
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
6
6
|
export const EXECUTOR_LINK_PARAGRAPH = "Before any other work, read this executor's own durable Codex thread ID from the current task's CODEX_THREAD_ID environment value and call the TaskChef link_task MCP tool with that thread ID and the marked TaskChef task ID. Never use CODEX_SESSION_ID or the parent or delegator thread ID. If linking fails, CODEX_THREAD_ID is unavailable, or the tool is unavailable, report the failure visibly and retry on a later turn; do not guess an identity or continue substantive work while the task is link-pending.";
|
|
7
7
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
8
|
-
export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working,
|
|
8
|
+
export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working, summary omitted or null, and a concise requestSummary for this turn. link_task remains the first TaskChef action on the initial turn; do not report working before identity is linked. Never reuse a prior turn ID after a follow-up.";
|
|
9
9
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
10
10
|
export const EXECUTOR_RESULT_PARAGRAPH = "Before ending, read this exact Codex thread again and call TaskChef report_state for the same current working turn with status completed, needs_input, or failed and a concise summary. Use needs_input only for a semantic decision or information the user must provide; a native approval prompt is live Codex state, not a TaskChef result. Do not include secrets, transcripts, or raw command output.";
|
|
11
11
|
export const EXECUTOR_SKILL_INVOCATION = "Use $taskchef-executor to execute and report this delegated TaskChef assignment.";
|
|
@@ -162,14 +162,18 @@ export function parseTaskChefMarker(instruction) {
|
|
|
162
162
|
return hasHistoricalAssignment() ? id : null;
|
|
163
163
|
}
|
|
164
164
|
const executorSkillReferences = instruction.match(/\$taskchef-executor\b/gi) ?? [];
|
|
165
|
+
const hasCompactBoundary = index >= 1
|
|
166
|
+
&& lines.at(index - 1).trim().length > 0;
|
|
167
|
+
const hasHistoricalBlankBoundary = index >= 2
|
|
168
|
+
&& lines.at(index - 1) === ""
|
|
169
|
+
&& lines.at(index - 2).trim().length > 0;
|
|
170
|
+
const assignmentEnd = hasCompactBoundary ? index : index - 1;
|
|
165
171
|
const isTrailingScaffold = index === lines.length - 2
|
|
166
|
-
&&
|
|
172
|
+
&& (hasCompactBoundary || hasHistoricalBlankBoundary)
|
|
167
173
|
&& lines[0].trim().length > 0
|
|
168
|
-
&& lines.at(-3) === ""
|
|
169
|
-
&& lines.at(-4).trim().length > 0
|
|
170
174
|
&& lines.at(-1) === EXECUTOR_SKILL_INVOCATION
|
|
171
|
-
&& hasTaskSpecificContent(lines.slice(0,
|
|
172
|
-
&& !lines.slice(0,
|
|
175
|
+
&& hasTaskSpecificContent(lines.slice(0, assignmentEnd))
|
|
176
|
+
&& !lines.slice(0, assignmentEnd).some((line) => HISTORICAL_EXECUTOR_SCAFFOLD_LINES.has(line))
|
|
173
177
|
&& executorSkillReferences.length === 1;
|
|
174
178
|
return isTrailingScaffold ? id : null;
|
|
175
179
|
}
|
|
@@ -198,7 +202,7 @@ export function prepareDelegation(instruction, { taskId = randomUUID() } = {}) {
|
|
|
198
202
|
const id = requireUuid(taskId);
|
|
199
203
|
return {
|
|
200
204
|
id,
|
|
201
|
-
instruction: `${body}\n
|
|
205
|
+
instruction: `${body}\n${taskChefMarker(id)}\n${EXECUTOR_SKILL_INVOCATION}`,
|
|
202
206
|
};
|
|
203
207
|
}
|
|
204
208
|
|
package/src/mcp.js
CHANGED
|
@@ -21,7 +21,7 @@ const projectSchema = z.object({
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const taskSchema = z.object({
|
|
24
|
-
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
|
|
24
|
+
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
|
|
25
25
|
id: z.string(),
|
|
26
26
|
project: projectSchema,
|
|
27
27
|
title: z.string(),
|
|
@@ -33,6 +33,26 @@ const taskSchema = z.object({
|
|
|
33
33
|
turnId: z.string().nullable(),
|
|
34
34
|
updatedAt: z.string(),
|
|
35
35
|
updatedBy: z.enum(["dispatcher", "mcp"]),
|
|
36
|
+
turns: z.array(z.object({
|
|
37
|
+
turnId: z.string().nullable(),
|
|
38
|
+
requestSummary: z.string().nullable(),
|
|
39
|
+
startedAt: z.string(),
|
|
40
|
+
result: z.object({
|
|
41
|
+
status: z.enum(["needs_input", "completed", "failed"]),
|
|
42
|
+
summary: z.string(),
|
|
43
|
+
updatedAt: z.string(),
|
|
44
|
+
}).nullable(),
|
|
45
|
+
})),
|
|
46
|
+
latestTurn: z.object({
|
|
47
|
+
turnId: z.string().nullable(),
|
|
48
|
+
requestSummary: z.string().nullable(),
|
|
49
|
+
startedAt: z.string(),
|
|
50
|
+
result: z.object({
|
|
51
|
+
status: z.enum(["needs_input", "completed", "failed"]),
|
|
52
|
+
summary: z.string(),
|
|
53
|
+
updatedAt: z.string(),
|
|
54
|
+
}).nullable(),
|
|
55
|
+
}).nullable(),
|
|
36
56
|
results: z.array(z.object({
|
|
37
57
|
status: z.enum(["needs_input", "completed", "failed"]),
|
|
38
58
|
summary: z.string(),
|
|
@@ -203,13 +223,14 @@ export function createTaskChefMcpServer({
|
|
|
203
223
|
{
|
|
204
224
|
title: "Report TaskChef state",
|
|
205
225
|
description:
|
|
206
|
-
"Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary. Exact retries are idempotent; stale or mismatched turns are rejected.",
|
|
226
|
+
"Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null and a concise requestSummary. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary and requestSummary omitted. Exact retries are idempotent; stale or mismatched turns are rejected.",
|
|
207
227
|
inputSchema: {
|
|
208
228
|
taskId: z.string().min(1),
|
|
209
229
|
threadId: z.string().min(1).nullable(),
|
|
210
230
|
turnId: z.string().min(1).max(256).nullable(),
|
|
211
231
|
status: z.enum(["working", "needs_input", "completed", "failed"]),
|
|
212
232
|
summary: z.string().min(1).max(2_000).nullable().optional(),
|
|
233
|
+
requestSummary: z.string().min(1).max(1_000).nullable().optional(),
|
|
213
234
|
},
|
|
214
235
|
outputSchema: { task: taskSchema },
|
|
215
236
|
annotations: {
|
package/src/workspace.js
CHANGED
|
@@ -38,8 +38,8 @@ const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
|
38
38
|
const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
|
|
39
39
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
40
40
|
const CURRENT_CONFIG_SCHEMA_VERSION = 2;
|
|
41
|
-
const CURRENT_TASK_SCHEMA_VERSION =
|
|
42
|
-
const PREVIOUS_TASK_SCHEMA_VERSION =
|
|
41
|
+
const CURRENT_TASK_SCHEMA_VERSION = 7;
|
|
42
|
+
const PREVIOUS_TASK_SCHEMA_VERSION = 6;
|
|
43
43
|
const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
|
|
44
44
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
45
45
|
const PROJECT_FIELDS = new Set([
|
|
@@ -65,7 +65,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
|
|
|
65
65
|
"updatedBy",
|
|
66
66
|
]);
|
|
67
67
|
const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
|
|
68
|
-
const
|
|
68
|
+
const SCHEMA_6_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
|
|
69
|
+
const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
|
|
69
70
|
const RECORD_DISPATCH_FIELDS = new Set([
|
|
70
71
|
"id",
|
|
71
72
|
"project",
|
|
@@ -77,7 +78,10 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
|
|
|
77
78
|
const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
|
|
78
79
|
const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
|
|
79
80
|
const MAX_RESULT_SUMMARY_LENGTH = 2_000;
|
|
81
|
+
const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
|
|
80
82
|
const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
|
|
83
|
+
const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
|
|
84
|
+
const TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
|
|
81
85
|
|
|
82
86
|
function requireExactFields(value, fields, name) {
|
|
83
87
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -224,7 +228,7 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
|
224
228
|
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
225
229
|
const content = await readFile(dispatchPath, "utf8");
|
|
226
230
|
const appended = dispatches
|
|
227
|
-
.map((dispatch) => `${JSON.stringify(
|
|
231
|
+
.map((dispatch) => `${JSON.stringify(schema7Task(dispatch))}\n`)
|
|
228
232
|
.join("");
|
|
229
233
|
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
230
234
|
}
|
|
@@ -684,6 +688,7 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
684
688
|
async function validateDispatchShape(dispatch, name = "task") {
|
|
685
689
|
const supportedVersions = [
|
|
686
690
|
FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
|
|
691
|
+
5,
|
|
687
692
|
PREVIOUS_TASK_SCHEMA_VERSION,
|
|
688
693
|
CURRENT_TASK_SCHEMA_VERSION,
|
|
689
694
|
];
|
|
@@ -695,6 +700,8 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
695
700
|
dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
696
701
|
? DISPATCH_FIELDS
|
|
697
702
|
: dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
|
|
703
|
+
? SCHEMA_6_DISPATCH_FIELDS
|
|
704
|
+
: dispatch.schemaVersion === 5
|
|
698
705
|
? SCHEMA_5_DISPATCH_FIELDS
|
|
699
706
|
: STATEFUL_DISPATCH_FIELDS,
|
|
700
707
|
name,
|
|
@@ -735,20 +742,77 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
735
742
|
}
|
|
736
743
|
return normalizedResult;
|
|
737
744
|
};
|
|
738
|
-
|
|
745
|
+
const normalizeTurnResult = (result, resultName) => {
|
|
746
|
+
if (result === null) return null;
|
|
747
|
+
requireExactFields(result, TURN_RESULT_FIELDS, resultName);
|
|
748
|
+
const normalizedResult = {
|
|
749
|
+
status: requireEnum(result.status, RESULT_STATUSES, `${resultName}.status`),
|
|
750
|
+
summary: optionalString(result.summary, `${resultName}.summary`, {
|
|
751
|
+
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
752
|
+
}),
|
|
753
|
+
updatedAt: requireTimestamp(result.updatedAt, `${resultName}.updatedAt`),
|
|
754
|
+
};
|
|
755
|
+
if (normalizedResult.summary === null) {
|
|
756
|
+
throw new Error(`${resultName}.summary must be a non-empty string`);
|
|
757
|
+
}
|
|
758
|
+
return normalizedResult;
|
|
759
|
+
};
|
|
760
|
+
const normalizeTurn = (turn, turnName) => {
|
|
761
|
+
requireExactFields(turn, TURN_FIELDS, turnName);
|
|
762
|
+
return {
|
|
763
|
+
turnId: optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 }),
|
|
764
|
+
requestSummary: optionalString(turn.requestSummary, `${turnName}.requestSummary`, {
|
|
765
|
+
maxLength: MAX_REQUEST_SUMMARY_LENGTH,
|
|
766
|
+
}),
|
|
767
|
+
startedAt: requireTimestamp(turn.startedAt, `${turnName}.startedAt`),
|
|
768
|
+
result: normalizeTurnResult(turn.result, `${turnName}.result`),
|
|
769
|
+
};
|
|
770
|
+
};
|
|
771
|
+
let legacyResults = [];
|
|
772
|
+
let turns = [];
|
|
739
773
|
if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
|
|
774
|
+
if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
|
|
775
|
+
turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
|
|
776
|
+
} else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
|
|
740
777
|
if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
|
|
741
|
-
|
|
778
|
+
legacyResults = dispatch.results.map((result, index) => (
|
|
742
779
|
normalizeResult(result, `${name}.results[${index}]`)
|
|
743
780
|
));
|
|
744
|
-
} else if (dispatch.schemaVersion ===
|
|
781
|
+
} else if (dispatch.schemaVersion === 5) {
|
|
745
782
|
if (dispatch.lastResult !== null) {
|
|
746
|
-
|
|
783
|
+
legacyResults = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
|
|
747
784
|
}
|
|
748
785
|
} else if (RESULT_STATUSES.has(status)) {
|
|
749
|
-
|
|
786
|
+
legacyResults = [{ status, summary, turnId, updatedAt }];
|
|
750
787
|
}
|
|
788
|
+
if (dispatch.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
|
|
789
|
+
turns = legacyResults.map((result) => ({
|
|
790
|
+
turnId: result.turnId,
|
|
791
|
+
requestSummary: null,
|
|
792
|
+
startedAt: result.updatedAt,
|
|
793
|
+
result: {
|
|
794
|
+
status: result.status,
|
|
795
|
+
summary: result.summary,
|
|
796
|
+
updatedAt: result.updatedAt,
|
|
797
|
+
},
|
|
798
|
+
}));
|
|
799
|
+
if (
|
|
800
|
+
status === "working"
|
|
801
|
+
&& turnId !== null
|
|
802
|
+
&& (
|
|
803
|
+
turns.at(-1)?.turnId !== turnId
|
|
804
|
+
|| turns.at(-1)?.result !== null
|
|
805
|
+
)
|
|
806
|
+
) {
|
|
807
|
+
turns.push({ turnId, requestSummary: null, startedAt: updatedAt, result: null });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const results = turns.flatMap((turn) => turn.result === null ? [] : [{
|
|
811
|
+
...turn.result,
|
|
812
|
+
turnId: turn.turnId,
|
|
813
|
+
}]);
|
|
751
814
|
const lastResult = results.at(-1) ?? null;
|
|
815
|
+
const latestTurn = turns.at(-1) ?? null;
|
|
752
816
|
const normalized = {
|
|
753
817
|
schemaVersion: dispatch.schemaVersion,
|
|
754
818
|
id,
|
|
@@ -764,6 +828,8 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
764
828
|
turnId,
|
|
765
829
|
updatedAt,
|
|
766
830
|
updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
|
|
831
|
+
turns,
|
|
832
|
+
latestTurn,
|
|
767
833
|
results,
|
|
768
834
|
lastResult,
|
|
769
835
|
};
|
|
@@ -773,14 +839,20 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
773
839
|
if (normalized.turnId !== null) {
|
|
774
840
|
normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
|
|
775
841
|
}
|
|
776
|
-
for (const [index,
|
|
777
|
-
if (
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
`${name}.
|
|
842
|
+
for (const [index, turn] of normalized.turns.entries()) {
|
|
843
|
+
if (turn.turnId != null) {
|
|
844
|
+
turn.turnId = normalizeCodexThreadId(
|
|
845
|
+
turn.turnId,
|
|
846
|
+
`${name}.turns[${index}].turnId`,
|
|
781
847
|
);
|
|
782
848
|
}
|
|
783
849
|
}
|
|
850
|
+
normalized.results = normalized.turns.flatMap((turn) => turn.result === null ? [] : [{
|
|
851
|
+
...turn.result,
|
|
852
|
+
turnId: turn.turnId,
|
|
853
|
+
}]);
|
|
854
|
+
normalized.lastResult = normalized.results.at(-1) ?? null;
|
|
855
|
+
normalized.latestTurn = normalized.turns.at(-1) ?? null;
|
|
784
856
|
}
|
|
785
857
|
if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
|
|
786
858
|
if (normalized.threadId === null) {
|
|
@@ -814,7 +886,7 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
814
886
|
if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
|
|
815
887
|
throw new Error(`${name}.summary is required for status ${normalized.status}`);
|
|
816
888
|
}
|
|
817
|
-
if (normalized.schemaVersion >=
|
|
889
|
+
if (normalized.schemaVersion >= 5) {
|
|
818
890
|
if (RESULT_STATUSES.has(normalized.status)) {
|
|
819
891
|
if (
|
|
820
892
|
normalized.lastResult === null
|
|
@@ -850,32 +922,76 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
850
922
|
throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
|
|
851
923
|
}
|
|
852
924
|
}
|
|
853
|
-
|
|
925
|
+
if (normalized.status === "working") {
|
|
926
|
+
if (normalized.turnId === null) {
|
|
927
|
+
if (normalized.turns.length !== 0) {
|
|
928
|
+
throw new Error(`${name}.turns must be empty before the first working turn`);
|
|
929
|
+
}
|
|
930
|
+
} else if (
|
|
931
|
+
normalized.latestTurn === null
|
|
932
|
+
|| normalized.latestTurn.turnId !== normalized.turnId
|
|
933
|
+
|| normalized.latestTurn.result !== null
|
|
934
|
+
) {
|
|
935
|
+
throw new Error(`${name}.latestTurn must match the current working turn`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (RESULT_STATUSES.has(normalized.status)) {
|
|
939
|
+
if (
|
|
940
|
+
normalized.latestTurn === null
|
|
941
|
+
|| normalized.latestTurn.turnId !== normalized.turnId
|
|
942
|
+
|| normalized.latestTurn.result === null
|
|
943
|
+
|| normalized.latestTurn.result.status !== normalized.status
|
|
944
|
+
|| normalized.latestTurn.result.summary !== normalized.summary
|
|
945
|
+
|| normalized.latestTurn.result.updatedAt !== normalized.updatedAt
|
|
946
|
+
) {
|
|
947
|
+
throw new Error(`${name}.latestTurn must match the current semantic state`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const seenTurnIds = new Set();
|
|
854
951
|
const nullTurnKey = Symbol("null turn");
|
|
855
|
-
for (const [index,
|
|
856
|
-
const turnKey =
|
|
857
|
-
|
|
858
|
-
|
|
952
|
+
for (const [index, turn] of normalized.turns.entries()) {
|
|
953
|
+
const turnKey = turn.turnId ?? nullTurnKey;
|
|
954
|
+
const isLegacyOpaqueWorkingReuse = (
|
|
955
|
+
!isSelfLinkingRecord
|
|
956
|
+
&& normalized.status === "working"
|
|
957
|
+
&& index === normalized.turns.length - 1
|
|
958
|
+
&& turn.result === null
|
|
959
|
+
&& index > 0
|
|
960
|
+
&& normalized.turns[index - 1].turnId === turn.turnId
|
|
961
|
+
&& normalized.turns[index - 1].result !== null
|
|
962
|
+
);
|
|
963
|
+
if (seenTurnIds.has(turnKey) && !isLegacyOpaqueWorkingReuse) {
|
|
964
|
+
throw new Error(`${name}.turns contains duplicate turnId: ${turn.turnId ?? "null"}`);
|
|
859
965
|
}
|
|
860
|
-
|
|
861
|
-
if (Date.parse(
|
|
862
|
-
throw new Error(`${name}.
|
|
966
|
+
seenTurnIds.add(turnKey);
|
|
967
|
+
if (Date.parse(turn.startedAt) < Date.parse(normalized.createdAt)) {
|
|
968
|
+
throw new Error(`${name}.turns[${index}].startedAt must not be earlier than createdAt`);
|
|
863
969
|
}
|
|
864
|
-
if (Date.parse(
|
|
865
|
-
throw new Error(`${name}.
|
|
970
|
+
if (Date.parse(turn.startedAt) > Date.parse(normalized.updatedAt)) {
|
|
971
|
+
throw new Error(`${name}.turns[${index}].startedAt must not be later than updatedAt`);
|
|
866
972
|
}
|
|
867
973
|
if (
|
|
868
974
|
index > 0
|
|
869
|
-
&& Date.parse(
|
|
975
|
+
&& Date.parse(turn.startedAt) < Date.parse(normalized.turns[index - 1].startedAt)
|
|
870
976
|
) {
|
|
871
|
-
throw new Error(`${name}.
|
|
977
|
+
throw new Error(`${name}.turns must be ordered by startedAt`);
|
|
872
978
|
}
|
|
873
979
|
if (
|
|
874
980
|
isSelfLinkingRecord
|
|
875
981
|
&& index > 0
|
|
876
|
-
&&
|
|
982
|
+
&& turn.turnId <= normalized.turns[index - 1].turnId
|
|
877
983
|
) {
|
|
878
|
-
throw new Error(`${name}.
|
|
984
|
+
throw new Error(`${name}.turns must be ordered by turnId`);
|
|
985
|
+
}
|
|
986
|
+
if (turn.result !== null) {
|
|
987
|
+
if (Date.parse(turn.result.updatedAt) < Date.parse(turn.startedAt)) {
|
|
988
|
+
throw new Error(`${name}.turns[${index}].result.updatedAt must not be earlier than startedAt`);
|
|
989
|
+
}
|
|
990
|
+
if (Date.parse(turn.result.updatedAt) > Date.parse(normalized.updatedAt)) {
|
|
991
|
+
throw new Error(`${name}.turns[${index}].result.updatedAt must not be later than updatedAt`);
|
|
992
|
+
}
|
|
993
|
+
} else if (index !== normalized.turns.length - 1) {
|
|
994
|
+
throw new Error(`${name}.turns may contain an unfinished turn only at the end`);
|
|
879
995
|
}
|
|
880
996
|
}
|
|
881
997
|
if (
|
|
@@ -960,12 +1076,17 @@ export async function parseTaskLogContent(workspaceRoot, content) {
|
|
|
960
1076
|
return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
|
|
961
1077
|
}
|
|
962
1078
|
|
|
963
|
-
function
|
|
964
|
-
const {
|
|
1079
|
+
function schema7Task(dispatch, patch = {}) {
|
|
1080
|
+
const {
|
|
1081
|
+
latestTurn: _latestTurn,
|
|
1082
|
+
results: _results,
|
|
1083
|
+
lastResult: _lastResult,
|
|
1084
|
+
...persisted
|
|
1085
|
+
} = dispatch;
|
|
965
1086
|
return {
|
|
966
1087
|
...persisted,
|
|
967
1088
|
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
968
|
-
|
|
1089
|
+
turns: dispatch.turns ?? [],
|
|
969
1090
|
...patch,
|
|
970
1091
|
};
|
|
971
1092
|
}
|
|
@@ -994,13 +1115,13 @@ export async function migrateTaskLog(workspaceRoot, {
|
|
|
994
1115
|
backupPath: null,
|
|
995
1116
|
};
|
|
996
1117
|
}
|
|
997
|
-
const lines = records.map((record) => JSON.stringify(
|
|
1118
|
+
const lines = records.map((record) => JSON.stringify(schema7Task(record.normalized)));
|
|
998
1119
|
const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
999
1120
|
await parseDispatchRecordsUnlocked(root, migrated);
|
|
1000
1121
|
const timestamp = requireTimestamp(now(), "migration timestamp")
|
|
1001
1122
|
.replaceAll(":", "-")
|
|
1002
1123
|
.replaceAll(".", "-");
|
|
1003
|
-
const backupPath = `${dispatchPath}.pre-
|
|
1124
|
+
const backupPath = `${dispatchPath}.pre-v7-${timestamp}-${randomUUID()}.bak`;
|
|
1004
1125
|
await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
1005
1126
|
if (await readFile(backupPath, "utf8") !== original) {
|
|
1006
1127
|
throw new Error(`task log backup validation failed: ${backupPath}`);
|
|
@@ -1046,7 +1167,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
1046
1167
|
turnId: null,
|
|
1047
1168
|
updatedAt: createdAt,
|
|
1048
1169
|
updatedBy: "dispatcher",
|
|
1049
|
-
|
|
1170
|
+
turns: [],
|
|
1050
1171
|
});
|
|
1051
1172
|
const existing = await readDispatchesUnlocked(root);
|
|
1052
1173
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
@@ -1088,7 +1209,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1088
1209
|
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
1089
1210
|
}
|
|
1090
1211
|
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
1091
|
-
const canonical = await validateDispatchShape(
|
|
1212
|
+
const canonical = await validateDispatchShape(schema7Task(dispatch, {
|
|
1092
1213
|
threadId: durableThreadId,
|
|
1093
1214
|
updatedAt: transitionTimestamp(now, dispatch.updatedAt),
|
|
1094
1215
|
updatedBy: "mcp",
|
|
@@ -1117,7 +1238,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1117
1238
|
))) {
|
|
1118
1239
|
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
1119
1240
|
}
|
|
1120
|
-
const linked = await validateDispatchShape(
|
|
1241
|
+
const linked = await validateDispatchShape(schema7Task(dispatch, {
|
|
1121
1242
|
threadId: durableThreadId,
|
|
1122
1243
|
updatedAt: transitionTimestamp(now, dispatch.updatedAt),
|
|
1123
1244
|
updatedBy: "mcp",
|
|
@@ -1131,11 +1252,13 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1131
1252
|
}
|
|
1132
1253
|
|
|
1133
1254
|
function dispatchLineWithState(dispatch, patch) {
|
|
1134
|
-
return JSON.stringify(
|
|
1255
|
+
return JSON.stringify(schema7Task(dispatch, patch));
|
|
1135
1256
|
}
|
|
1136
1257
|
|
|
1137
1258
|
function normalizeTaskStateInput(input, { allowWorking }) {
|
|
1138
|
-
const fields = new Set([
|
|
1259
|
+
const fields = new Set([
|
|
1260
|
+
"taskId", "threadId", "turnId", "status", "summary", "requestSummary",
|
|
1261
|
+
]);
|
|
1139
1262
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
1140
1263
|
throw new Error("task state must be an object");
|
|
1141
1264
|
}
|
|
@@ -1157,13 +1280,21 @@ function normalizeTaskStateInput(input, { allowWorking }) {
|
|
|
1157
1280
|
const summary = optionalString("summary" in input ? input.summary : null, "summary", {
|
|
1158
1281
|
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
1159
1282
|
});
|
|
1283
|
+
const requestSummary = optionalString(
|
|
1284
|
+
"requestSummary" in input ? input.requestSummary : null,
|
|
1285
|
+
"requestSummary",
|
|
1286
|
+
{ maxLength: MAX_REQUEST_SUMMARY_LENGTH },
|
|
1287
|
+
);
|
|
1160
1288
|
if (status === "working" && summary !== null) {
|
|
1161
1289
|
throw new Error("summary must be null while status is working");
|
|
1162
1290
|
}
|
|
1163
1291
|
if (status !== "working" && summary === null) {
|
|
1164
1292
|
throw new Error(`summary is required for status ${status}`);
|
|
1165
1293
|
}
|
|
1166
|
-
|
|
1294
|
+
if (status !== "working" && requestSummary !== null) {
|
|
1295
|
+
throw new Error(`requestSummary is accepted only for status working`);
|
|
1296
|
+
}
|
|
1297
|
+
return { id, threadId, turnId, status, summary, requestSummary };
|
|
1167
1298
|
}
|
|
1168
1299
|
|
|
1169
1300
|
function sameLastResult(lastResult, { status, summary, turnId }) {
|
|
@@ -1178,7 +1309,7 @@ async function reportTaskStateInternal(
|
|
|
1178
1309
|
input,
|
|
1179
1310
|
{ now, compatibilityAlias = false } = {},
|
|
1180
1311
|
) {
|
|
1181
|
-
const { id, threadId, turnId, status, summary } = normalizeTaskStateInput(input, {
|
|
1312
|
+
const { id, threadId, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
|
|
1182
1313
|
allowWorking: !compatibilityAlias,
|
|
1183
1314
|
});
|
|
1184
1315
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
@@ -1242,22 +1373,51 @@ async function reportTaskStateInternal(
|
|
|
1242
1373
|
}
|
|
1243
1374
|
}
|
|
1244
1375
|
if (status === "working") {
|
|
1245
|
-
|
|
1376
|
+
const sameWorkingTurn = dispatch.status === "working" && stateTurnId === dispatch.turnId;
|
|
1377
|
+
if (sameWorkingTurn) {
|
|
1378
|
+
const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
|
|
1379
|
+
if (
|
|
1380
|
+
requestSummary !== null
|
|
1381
|
+
&& storedRequest !== null
|
|
1382
|
+
&& requestSummary !== storedRequest
|
|
1383
|
+
) {
|
|
1384
|
+
throw new Error(`working turn already has a different requestSummary: ${id}`);
|
|
1385
|
+
}
|
|
1386
|
+
if (
|
|
1387
|
+
records[index].raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
1388
|
+
&& (requestSummary === null || storedRequest === requestSummary)
|
|
1389
|
+
) {
|
|
1390
|
+
return dispatch;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1246
1393
|
if (isSelfLinkingJourney) {
|
|
1247
|
-
if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
|
|
1394
|
+
if (!sameWorkingTurn && dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
|
|
1248
1395
|
throw new Error(`working turnId must be newer than the current task turnId: ${id}`);
|
|
1249
1396
|
}
|
|
1250
|
-
if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
|
|
1397
|
+
if (!sameWorkingTurn && dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
|
|
1251
1398
|
throw new Error(`working turnId must be newer than the last result turnId: ${id}`);
|
|
1252
1399
|
}
|
|
1253
1400
|
}
|
|
1254
|
-
const updatedAt =
|
|
1255
|
-
|
|
1401
|
+
const updatedAt = sameWorkingTurn
|
|
1402
|
+
? dispatch.updatedAt
|
|
1403
|
+
: transitionTimestamp(now, dispatch.updatedAt);
|
|
1404
|
+
const turns = sameWorkingTurn
|
|
1405
|
+
? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
|
|
1406
|
+
? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
|
|
1407
|
+
: turn)
|
|
1408
|
+
: [...dispatch.turns, {
|
|
1409
|
+
turnId: stateTurnId,
|
|
1410
|
+
requestSummary,
|
|
1411
|
+
startedAt: updatedAt,
|
|
1412
|
+
result: null,
|
|
1413
|
+
}];
|
|
1414
|
+
const updated = await validateDispatchShape(schema7Task(dispatch, {
|
|
1256
1415
|
status,
|
|
1257
1416
|
summary: null,
|
|
1258
1417
|
turnId: stateTurnId,
|
|
1259
1418
|
updatedAt,
|
|
1260
1419
|
updatedBy: "mcp",
|
|
1420
|
+
turns,
|
|
1261
1421
|
}));
|
|
1262
1422
|
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1263
1423
|
? dispatchLineWithState(updated, {})
|
|
@@ -1265,7 +1425,10 @@ async function reportTaskStateInternal(
|
|
|
1265
1425
|
await writeDispatchLinesAtomic(root, lines);
|
|
1266
1426
|
return updated;
|
|
1267
1427
|
}
|
|
1268
|
-
const
|
|
1428
|
+
const priorTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
|
|
1429
|
+
const priorTurnResult = priorTurn?.result === null || priorTurn === undefined
|
|
1430
|
+
? null
|
|
1431
|
+
: { ...priorTurn.result, turnId: priorTurn.turnId };
|
|
1269
1432
|
if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
|
|
1270
1433
|
return dispatch;
|
|
1271
1434
|
}
|
|
@@ -1287,14 +1450,28 @@ async function reportTaskStateInternal(
|
|
|
1287
1450
|
throw new Error(`task result must match the current working turnId: ${id}`);
|
|
1288
1451
|
}
|
|
1289
1452
|
const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
|
|
1290
|
-
const
|
|
1291
|
-
const
|
|
1453
|
+
const turnResult = { status, summary, updatedAt };
|
|
1454
|
+
const currentTurnIndex = dispatch.turns.findIndex((turn) => turn.turnId === stateTurnId);
|
|
1455
|
+
let turns;
|
|
1456
|
+
if (currentTurnIndex === -1) {
|
|
1457
|
+
turns = [...dispatch.turns, {
|
|
1458
|
+
turnId: stateTurnId,
|
|
1459
|
+
requestSummary: null,
|
|
1460
|
+
startedAt: updatedAt,
|
|
1461
|
+
result: turnResult,
|
|
1462
|
+
}];
|
|
1463
|
+
} else {
|
|
1464
|
+
turns = dispatch.turns.map((turn, turnIndex) => turnIndex === currentTurnIndex
|
|
1465
|
+
? { ...turn, result: turnResult }
|
|
1466
|
+
: turn);
|
|
1467
|
+
}
|
|
1468
|
+
const candidate = schema7Task(dispatch, {
|
|
1292
1469
|
status,
|
|
1293
1470
|
summary,
|
|
1294
1471
|
turnId: stateTurnId,
|
|
1295
1472
|
updatedAt,
|
|
1296
1473
|
updatedBy: "mcp",
|
|
1297
|
-
|
|
1474
|
+
turns,
|
|
1298
1475
|
});
|
|
1299
1476
|
const updated = await validateDispatchShape(candidate);
|
|
1300
1477
|
const lines = records.map((record, recordIndex) => recordIndex === index
|