taskchef 7.4.0 → 7.5.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.4.0",
3
+ "version": "7.5.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  TaskChef is a local dispatch desk for Codex. Give one dispatcher a request and
4
4
  it records each independently useful outcome, creates a normal Codex task in
5
5
  the right project, and returns immediately. The executor task is where live
6
- work, approvals, and follow-ups happen; TaskChef keeps the latest compact
7
- snapshot for navigation and reporting.
6
+ work, approvals, and follow-ups happen; TaskChef keeps every semantic result
7
+ while projecting the latest one for compact navigation and reporting.
8
8
 
9
9
  ```text
10
- request -> recorded TaskChef task -> Codex executor -> current state + last result
10
+ request -> recorded TaskChef task -> Codex executor -> current state + result history
11
11
  ```
12
12
 
13
13
  ## Which document should I read?
@@ -49,7 +49,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
49
49
  ```text
50
50
  AGENTS.md managed dispatcher instructions plus user additions
51
51
  taskchef.json schema-2 configured projects and routing metadata
52
- tasks.jsonl one schema-4/5 snapshot per task (new writes use schema 5)
52
+ tasks.jsonl one task snapshot per line (schema 6; schema 4/5 migration supported)
53
53
  ```
54
54
 
55
55
  List or change routing targets conversationally:
@@ -119,10 +119,12 @@ when a turn starts and one semantic outcome before that same turn ends:
119
119
  - `failed`: the executor or creation attempt ended unsuccessfully.
120
120
 
121
121
  A native approval prompt is live Codex state, not `needs_input`.
122
- TaskChef stores the current reported execution state and separately preserves
123
- the last concise semantic result. A follow-up therefore appears as `working`
124
- immediately without erasing the previous outcome. TaskChef does not store the
125
- transcript or a lifecycle event log.
122
+ TaskChef stores the current reported execution state and appends every concise
123
+ semantic result to `results`. A follow-up therefore appears as `working`
124
+ immediately without erasing any prior outcome. The final entry is exposed as a
125
+ derived `lastResult` compatibility alias; it is not persisted independently and
126
+ is planned for removal in the next major version after callers move to
127
+ `results.at(-1)`. TaskChef does not store transcripts or non-semantic events.
126
128
 
127
129
  Delegated tasks created by earlier TaskChef versions remain compatible: their
128
130
  inline executor protocol still parses, self-links, and may use the deprecated
@@ -172,7 +174,9 @@ taskchef dashboard --port 3211
172
174
  ```
173
175
 
174
176
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
175
- linked Codex tasks. It does not mutate TaskChef data and prints its local URL.
177
+ linked Codex tasks. List snapshots and SSE events carry only the latest-result
178
+ projection; opening task details fetches the full newest-first result history.
179
+ It does not mutate TaskChef data and prints its local URL.
176
180
  When a compatible foreground dashboard already owns port 3210,
177
181
  `ensure_dashboard` reuses it but does not take ownership. If an unknown,
178
182
  different-workspace, or stale-version process owns the port, TaskChef reports a
@@ -183,6 +187,8 @@ The health endpoint contains only a fixed service marker, health schema,
183
187
  TaskChef version, dashboard-server version, and canonical workspace. It exposes
184
188
  no task data, credentials, environment variables, process control, or secrets.
185
189
 
190
+ ![Task detail result history](docs/images/result-history-dashboard.jpg)
191
+
186
192
  ## Common recovery
187
193
 
188
194
  Check the managed workspace:
@@ -190,20 +196,27 @@ Check the managed workspace:
190
196
  ```sh
191
197
  taskchef doctor
192
198
  taskchef workspace init
199
+ taskchef workspace migrate
193
200
  taskchef doctor
194
201
  ```
195
202
 
196
- `doctor` is read-only. `workspace init` creates missing current-schema files
197
- and refreshes managed instructions; it does not migrate unsupported
198
- configuration or task records.
203
+ `doctor` is read-only. `workspace init` creates missing files and refreshes
204
+ managed instructions. `workspace migrate` explicitly upgrades supported schema
205
+ 4/5 task lines to schema 6 under the workspace lock. It validates the complete
206
+ source and converted log before writing, creates an exclusive `tasks.jsonl.pre-v6-*.bak`
207
+ backup, atomically replaces the log, validates the result, and becomes an
208
+ idempotent no-op after migration. If replacement fails, the original remains
209
+ or the reported backup can be restored; unsupported or invalid input is rejected
210
+ before a backup or rewrite.
199
211
 
200
212
  If a new record has no thread ID, the executor is link-pending. Reopen that
201
213
  executor so its first action can retry `link_task`. Do not guess an identity
202
214
  or edit `tasks.jsonl`. If native task creation failed, the record is retained
203
215
  as `failed` with null thread and turn IDs.
204
216
 
205
- If an unsupported workspace must be retained, keep it as a backup and create a
206
- new current workspace. TaskChef provides no conversion or merge command.
217
+ Schemas other than 4, 5, and 6 remain unsupported. Retain such a workspace
218
+ unchanged and create a current workspace; the migration command deliberately
219
+ does not guess how to convert unknown formats.
207
220
 
208
221
  ## Boundaries
209
222
 
package/docs/spec.md CHANGED
@@ -23,7 +23,8 @@ is dated research, not contract.
23
23
  | **Self-linking** | The executor's one-way registration of its own canonical Codex UUIDv7 from `CODEX_THREAD_ID`. |
24
24
  | **Link-pending** | A working task whose `threadId` is null and `updatedBy` is `dispatcher`. |
25
25
  | **Current execution state** | The latest reported executor turn and its `working`, `needs_input`, `completed`, or `failed` status. |
26
- | **Last semantic result** | The most recent `completed`, `needs_input`, or `failed` outcome, preserved separately while a newer turn is working. |
26
+ | **Result history** | The ordered collection of every accepted `completed`, `needs_input`, or `failed` per-turn outcome. |
27
+ | **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
27
28
  | **Current turn ID** | The canonical Codex UUIDv7 returned by an exact native read of the linked executor for the turn being reported. |
28
29
  | **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
29
30
  | **Skill** | One packaged agent procedure: `taskchef-bootstrap`, `taskchef-delegate`, `taskchef-executor`, or `taskchef-report`. |
@@ -67,10 +68,10 @@ Names and paths MUST be unique. Git projects MUST be exact Git roots.
67
68
  Repository URLs MUST canonicalize to `https://github.com/<owner>/<repository>`
68
69
  and be case-insensitively deduplicated.
69
70
 
70
- `tasks.jsonl` MUST contain zero or more newline-terminated schema-4 or schema-5
71
- records, one per line. Schema 4 is read compatibility for the previously
72
- released format; every new record and state mutation MUST write schema 5.
73
- Other schemas or unsupported fields MUST be rejected without conversion.
71
+ `tasks.jsonl` MUST contain zero or more newline-terminated schema-4, schema-5,
72
+ or schema-6 records, one per line. Schemas 4 and 5 are supported migration/read
73
+ formats; every new record and state mutation MUST write schema 6. Other schemas
74
+ or unsupported fields MUST be rejected without conversion.
74
75
  Reads and writes MUST reject symlinked managed files. Mutations
75
76
  MUST hold the shared workspace lock and replace state atomically; read-only
76
77
  operations MUST NOT require write permission.
@@ -81,7 +82,7 @@ Every record MUST contain exactly these fields:
81
82
 
82
83
  | Field | Contract |
83
84
  | --- | --- |
84
- | `schemaVersion` | Integer `5`; schema-4 records remain readable until their next mutation. |
85
+ | `schemaVersion` | Integer `6`; schema-4/5 records remain readable until explicit migration or their next mutation. |
85
86
  | `id` | Unique safe TaskChef ID; delegation uses a lowercase full UUID. |
86
87
  | `project` | Immutable configured-project snapshot. |
87
88
  | `title` | Non-empty display title. |
@@ -93,7 +94,13 @@ Every record MUST contain exactly these fields:
93
94
  | `turnId` | Null before turn reporting; otherwise the current reported turn. Linked MCP journeys use a canonical Codex UUIDv7. |
94
95
  | `updatedAt` | ISO 8601 timestamp not earlier than `createdAt` or the prior `updatedAt`; clock rollback cannot backdate a transition. |
95
96
  | `updatedBy` | `dispatcher` or `mcp`. |
96
- | `lastResult` | Null before a semantic result; otherwise `{status, summary, turnId, updatedAt}` preserving the latest semantic result. |
97
+ | `results` | Ordered oldest-first array of `{status, summary, turnId, updatedAt}` semantic results, with unique turn IDs and nondecreasing timestamps. |
98
+
99
+ Returned Task objects MUST additionally expose `lastResult` as null for an empty
100
+ history or the final `results` entry. `lastResult` MUST be derived and MUST NOT
101
+ be persisted in schema 6. It is a compatibility alias for schema-4/5, CLI,
102
+ MCP, reporting-skill, and dashboard-list callers, with removal planned for the
103
+ next major version after callers migrate to `results.at(-1)`.
97
104
 
98
105
  Task IDs and non-null thread identities MUST be unique. The immutable intent
99
106
  fields MUST NOT change after recording.
@@ -231,7 +238,8 @@ new preparation values, though it writes no state.
231
238
 
232
239
  **Structured output:** `{ task: Task }`.
233
240
 
234
- The returned task has schema 5, `working`, null summary/turn/thread/lastResult,
241
+ The returned task has schema 6, `working`, null summary/turn/thread/lastResult,
242
+ an empty `results` array,
235
243
  `updatedBy: dispatcher`, and equal creation/update timestamps. Duplicate IDs,
236
244
  unknown projects, malformed markers, and invalid input fail. Repeating a
237
245
  successful call is not idempotent; it fails as a duplicate.
@@ -260,7 +268,7 @@ marker, or ineligible state fails.
260
268
  ### `report_state`
261
269
 
262
270
  **Caller:** executor, or dispatcher only for native creation failure.
263
- **Mutation:** replaces the current state atomically and preserves `lastResult`.
271
+ **Mutation:** replaces the current state atomically and preserves `results`.
264
272
 
265
273
  **Input:**
266
274
 
@@ -279,8 +287,10 @@ the current turn and last semantic result. A semantic state MUST match the
279
287
  current working turn. Repeating an identical state is idempotent; conflicting
280
288
  or older state fails. A null-identity record accepts only a fresh executor
281
289
  creation `failed` state with both IDs null. Success sets the current state and
282
- preserves the semantic state in `lastResult`; starting newer work does not erase
283
- that result.
290
+ appends a semantic result to `results`; starting newer work preserves the entire
291
+ collection. An identical retry for any settled turn returns success without an
292
+ append. A different result for a settled turn, a stale turn, or a semantic
293
+ result that does not match the active working turn MUST fail.
284
294
 
285
295
  **Annotations:** `readOnlyHint: false`, `destructiveHint: true`,
286
296
  `openWorldHint: false`.
@@ -289,10 +299,10 @@ that result.
289
299
 
290
300
  `report_result` retains the prior semantic-only input shape and statuses as a
291
301
  temporary compatibility alias. It implicitly accepts a fresh supplied turn and
292
- stores its semantic result, including for supported schema-4 records and
302
+ stores its semantic result, including for supported schema-4/5 records and
293
303
  low-level opaque direct records. It does not accept `working`. New executor
294
- instructions MUST use `report_state`. Successful mutation upgrades schema 4 to
295
- schema 5; unsupported schemas remain rejected.
304
+ instructions MUST use `report_state`. Successful mutation upgrades schema 4/5
305
+ to schema 6; unsupported schemas remain rejected.
296
306
 
297
307
  ## Reporting and dashboard
298
308
 
@@ -302,6 +312,7 @@ state overrides cache. An inactive task does not prove completion. Focused
302
312
  reports MAY read a selected task once when metadata is newer or evidence is
303
313
  uncertain. Reports MUST NOT poll or classify assistant prose.
304
314
 
315
+ Task lists, summaries, and broad reports MUST use the final result by default.
305
316
  The dashboard MUST bind only to loopback, validate the current workspace
306
317
  snapshot, and avoid sessions or shared client state. `GET /api/health` MUST
307
318
  return only the bounded service identity, health schema, exact TaskChef and
@@ -313,6 +324,21 @@ startup safely. Direct thread navigation
313
324
  MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
314
325
  configured project. Project paths from task history MUST be matched against
315
326
  current configuration before use.
327
+ Snapshot and SSE list payloads MUST omit full `results` history and include the
328
+ derived latest-result projection. The bounded per-task detail endpoint MAY
329
+ return the full validated task so the dialog can render history newest first.
330
+
331
+ ## Task-log migration
332
+
333
+ `workspace migrate` MUST explicitly convert every supported schema-4/5 record
334
+ under the shared lock. A legacy `lastResult`, or a schema-4 current semantic
335
+ state, becomes zero or one initial `results` entry. Migration MUST validate the
336
+ complete source and complete schema-6 candidate before changing the task log,
337
+ create and read back an exclusive recovery backup, atomically replace the log,
338
+ and validate the installed result. A fully schema-6 log MUST be an idempotent
339
+ no-op without another backup. Invalid/unsupported input MUST remain untouched;
340
+ failures after backup creation MUST report the backup path and MUST never
341
+ partially rewrite individual lines.
316
342
 
317
343
  ## Concurrency and trust
318
344
 
package/docs/workflows.md CHANGED
@@ -18,7 +18,7 @@ research.
18
18
  | `src/delegation.js` | UUID marker, concise executor-skill invocation shape, and creation-failure handling. |
19
19
  | `src/workspace.js` | Current schemas, validation, locking, atomic JSONL writes, linking, and result freshness. |
20
20
  | `src/cli.js` | Administration, inspection, diagnostics, and dashboard startup. |
21
- | `src/dashboard.js` | Versioned health identity, validated snapshots, SSE fan-out, and bounded open actions. |
21
+ | `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
22
22
  | `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
23
23
 
24
24
  The MCP process resolves `TASKCHEF_WORKSPACE` once and never accepts a model
@@ -89,7 +89,7 @@ sequenceDiagram
89
89
  D->>D: Choose one configured and native project
90
90
  D->>M: record_task(id, project, title, instruction, null)
91
91
  M->>W: recordTask()
92
- W->>W: Lock, validate, append schema-5 snapshot
92
+ W->>W: Lock, validate, append schema-6 snapshot
93
93
  W-->>M: working link-pending task
94
94
  M-->>D: task
95
95
  D->>C: Create executor with marked instruction
@@ -116,8 +116,8 @@ semantic callbacks.
116
116
  ## State reporting
117
117
 
118
118
  The executor obtains the turn identity from an exact native read of its own
119
- linked task. `report_state` records live turn state while preserving the last
120
- semantic result separately.
119
+ linked task. `report_state` records live turn state while preserving the ordered
120
+ semantic result history.
121
121
 
122
122
  ```mermaid
123
123
  sequenceDiagram
@@ -130,13 +130,13 @@ sequenceDiagram
130
130
  C-->>E: Current turn ID
131
131
  E->>M: report_state(..., working, null)
132
132
  M->>W: reportTaskState()
133
- W->>W: Store current turn and preserve lastResult
133
+ W->>W: Store current turn and preserve results
134
134
  E->>E: Work, finish, or reach semantic decision
135
135
  E->>M: report_state(..., semantic status, summary)
136
136
  M->>W: reportTaskState()
137
137
  W->>W: Lock and validate identity and freshness
138
138
  alt Same current working turn
139
- W->>W: Store semantic state and lastResult
139
+ W->>W: Append semantic result and derive lastResult
140
140
  W-->>M: Updated task
141
141
  M-->>E: Recorded result
142
142
  else Same turn and same result
@@ -173,9 +173,9 @@ sequenceDiagram
173
173
  C-->>E: turnB
174
174
  E->>M: report_state(..., turnB, working, null)
175
175
  M->>W: reportTaskState()
176
- W->>W: Require turnB greater and preserve result A
177
- W-->>M: working snapshot plus lastResult A
178
- M-->>E: working snapshot plus lastResult A
176
+ W->>W: Require turnB greater and preserve results
177
+ W-->>M: working snapshot plus history A
178
+ M-->>E: working snapshot plus history A
179
179
  E->>M: report_state(..., turnB, completed, summaryB)
180
180
  M->>W: reportTaskState()
181
181
  W-->>M: completed snapshot plus result B
@@ -263,7 +263,9 @@ sequenceDiagram
263
263
  F-->>D: Filesystem change
264
264
  D->>F: Bounded read from one descriptor
265
265
  D->>D: Validate current schema and sort
266
- D-->>B: SSE snapshot
266
+ D-->>B: Compact SSE snapshot without results
267
+ B->>D: GET task detail on demand
268
+ D-->>B: Full validated history
267
269
  B->>D: Open task action
268
270
  alt Canonical Codex UUIDv7
269
271
  D->>C: Direct thread navigation
@@ -273,10 +275,25 @@ sequenceDiagram
273
275
  end
274
276
  ```
275
277
 
276
- The dashboard binds to `127.0.0.1`, has no shared session state, limits
277
- request bodies, and checks origin/authority for stateful local actions. Its
278
- bounded identity endpoint contains no task data or secrets. Historical project
279
- paths are untrusted until matched against current configuration.
278
+ The dashboard binds to `127.0.0.1`, has no shared session state, limits task
279
+ count, file size, result count, and display fields, and checks origin/authority
280
+ for stateful local actions. Its bounded identity endpoint contains no task data
281
+ or secrets. The monitor already validates the complete log, but
282
+ snapshot/SSE list projections omit `results` so repeated updates do not resend
283
+ unnecessary history. A read-only per-task endpoint returns full history only
284
+ when the dialog opens. Historical project paths are untrusted until matched
285
+ against current configuration.
286
+
287
+ ## Schema 4/5 migration
288
+
289
+ `taskchef workspace migrate` acquires the same workspace lock as lifecycle
290
+ writers, validates the complete legacy log, converts each schema-4/5 latest
291
+ result into zero or one initial schema-6 `results` entry, then validates the
292
+ complete candidate. Before replacement it writes and reads back an exclusive
293
+ `tasks.jsonl.pre-v6-*.bak` file. The task log is replaced atomically and
294
+ validated again. A second run sees only schema 6 and returns unchanged without
295
+ another backup. Unsupported or malformed input fails before backup/rewrite;
296
+ after a later filesystem failure, the reported backup is the recovery source.
280
297
 
281
298
  ## Concurrency and trust boundaries
282
299
 
@@ -291,6 +308,7 @@ summary is cryptographically authenticated; this is a local single-user trust
291
308
  model. Managed files, instructions, project snapshots, MCP inputs, and dashboard
292
309
  requests are validated at every action boundary.
293
310
 
294
- Configuration schema 2 and task schemas 4 and 5 are accepted. Schema 4 is
295
- read-only compatibility until a lifecycle mutation upgrades that record to
296
- schema 5. Other schemas are rejected without rewrite.
311
+ Configuration schema 2 and task schemas 4, 5, and 6 are accepted. Schemas 4/5
312
+ are read/migration compatibility until an explicit migration or lifecycle
313
+ mutation upgrades each record to schema 6. Schema 6 persists `results` only and
314
+ derives `lastResult` for compatibility. Other schemas are rejected without rewrite.
package/index.js CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  initializeWorkspace,
11
11
  linkTask,
12
12
  listProjects,
13
+ migrateTaskLog,
13
14
  prepareDispatch,
14
15
  readConfig,
15
16
  listTasks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.4.0",
3
+ "version": "7.5.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -26,6 +26,7 @@
26
26
  "docs/spec.md",
27
27
  "docs/workflows.md",
28
28
  "docs/firstmate-taskchef-comparison.md",
29
+ "docs/images/result-history-dashboard.jpg",
29
30
  "index.js",
30
31
  "mcp",
31
32
  "scripts/benchmark-dispatch-prepare.js",
@@ -44,14 +44,16 @@ all deterministic task-log operations.
44
44
  detailed read. Native approval is live Codex state, not a `needs_input`
45
45
  callback. An inactive status never proves semantic completion; it only
46
46
  permits a trustworthy cached MCP result to stand.
47
- 4. In schema 5, treat `status`, `turnId`, and `updatedAt` as the latest reported
48
- execution state and treat `lastResult` as the separately preserved semantic
49
- result. A `working` state with a non-null `lastResult` means a newer executor
47
+ 4. In schema 6, treat `status`, `turnId`, and `updatedAt` as the latest reported
48
+ execution state and treat `results` as the ordered semantic history.
49
+ `lastResult` is the derived compatibility alias for `results.at(-1)`; prefer
50
+ the collection when full history matters and the alias for compact latest-state
51
+ reporting. A `working` state with a non-null `lastResult` means a newer executor
50
52
  turn started after that result; show the prior result as history, not as the
51
53
  current outcome. Treat a failed `lastResult` with null thread and turn IDs as
52
- a fresh executor-creation failure. No live read is possible or needed.
53
- Schema 4 snapshots normalize a structurally complete result into
54
- `lastResult` without rewriting their log line. When identity is certain and
54
+ a fresh executor-creation failure. Schema 4/5 snapshots normalize zero or one
55
+ semantic result into `results` and `lastResult` without rewriting their log line.
56
+ No live read is possible or needed for that creation failure. When identity is certain and
55
57
  metadata says the thread is inactive, trust the latest semantic result by
56
58
  default in a broad overview unless a newer working state makes it historical.
57
59
  Do not read every idle terminal task in an overview merely because native
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  initializeWorkspace,
15
15
  listProjects,
16
16
  listTasks,
17
+ migrateTaskLog,
17
18
  prepareDispatch,
18
19
  recordTask,
19
20
  removeProject,
@@ -182,11 +183,28 @@ function taskDetails(task) {
182
183
  `Updated by: ${singleLineDetail(task.updatedBy ?? "-")}`,
183
184
  `Task ID: ${singleLineDetail(task.id)}`,
184
185
  `Thread ID: ${singleLineDetail(task.threadId ?? "-")}`,
186
+ `Result count: ${task.results.length}`,
187
+ "Result history (newest first):",
188
+ ...[...task.results].reverse().map((result) => (
189
+ `- ${singleLineDetail(result.updatedAt)} | ${singleLineDetail(result.status)} | turn ${singleLineDetail(result.turnId ?? "-")} | ${singleLineDetail(result.summary)}`
190
+ )),
185
191
  "Instruction:",
186
192
  task.instruction,
187
193
  ].join("\n");
188
194
  }
189
195
 
196
+ async function migrate(args) {
197
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
198
+ const result = await migrateTaskLog(workspaceRoot(args));
199
+ print(result, args, (value) => [
200
+ `Task log: ${value.action}`,
201
+ `Tasks: ${value.taskCount}`,
202
+ `Migrated: ${value.migratedCount}`,
203
+ `Backup: ${value.backupPath ?? "not needed"}`,
204
+ ].join("\n"));
205
+ return 0;
206
+ }
207
+
190
208
  async function readTaskForShow(workspace, taskId) {
191
209
  const id = requireSafeId(taskId, "taskId");
192
210
  const tasks = await listTasks(workspace);
@@ -456,6 +474,7 @@ Usage:
456
474
  taskchef doctor [--json] [--workspace <path>]
457
475
  taskchef workspace path [--json] [--workspace <path>]
458
476
  taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
477
+ taskchef workspace migrate [--json] [--workspace <path>]
459
478
  taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
460
479
  taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
461
480
  taskchef project list [--json] [--workspace <path>]
@@ -489,6 +508,7 @@ export async function runCli(args) {
489
508
  if (args[0] === "doctor") return doctor(args);
490
509
  if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
491
510
  if (args[0] === "workspace" && args[1] === "init") return initialize(args);
511
+ if (args[0] === "workspace" && args[1] === "migrate") return migrate(args);
492
512
  if (args[0] === "project" && args[1] === "add") return projectAdd(args);
493
513
  if (args[0] === "project" && args[1] === "import") return projectImport(args);
494
514
  if (args[0] === "project" && args[1] === "list") return projectList(args);
@@ -17,6 +17,7 @@ const state = {
17
17
  selectedTask: null,
18
18
  };
19
19
  let dateRefreshTimer = null;
20
+ let detailRequestGeneration = 0;
20
21
 
21
22
  const elements = {
22
23
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -31,7 +32,7 @@ const elements = {
31
32
  dialogInstruction: document.querySelector("#dialog-instruction"),
32
33
  dialogMetadata: document.querySelector("#dialog-metadata"),
33
34
  dialogProject: document.querySelector("#dialog-project"),
34
- dialogSummary: document.querySelector("#dialog-summary"),
35
+ dialogResults: document.querySelector("#dialog-results"),
35
36
  dialogTitle: document.querySelector("#dialog-title"),
36
37
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
37
38
  emptyState: document.querySelector("#empty-state"),
@@ -119,12 +120,48 @@ function detailRow(term, value) {
119
120
  return [dt, dd];
120
121
  }
121
122
 
122
- function openDialog(task) {
123
- state.selectedTask = task;
123
+ function resultHistory(results) {
124
+ if (results.length === 0) {
125
+ const empty = document.createElement("p");
126
+ empty.className = "result-history-empty";
127
+ empty.textContent = "No semantic result has been reported yet.";
128
+ return [empty];
129
+ }
130
+ return [...results].reverse().map((result, index) => {
131
+ const item = document.createElement("article");
132
+ item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
133
+ const header = document.createElement("div");
134
+ header.className = "result-history-header";
135
+ const status = document.createElement("span");
136
+ status.className = `status status-${result.status}`;
137
+ status.textContent = result.status.replaceAll("_", " ");
138
+ const timestamp = document.createElement("time");
139
+ timestamp.dateTime = result.updatedAt;
140
+ timestamp.textContent = formatTime(result.updatedAt);
141
+ header.append(status, timestamp);
142
+ const summary = document.createElement("p");
143
+ summary.className = "preserve-lines";
144
+ summary.textContent = result.summary;
145
+ const turn = document.createElement("p");
146
+ turn.className = "result-history-turn";
147
+ turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
148
+ item.append(header, summary, turn);
149
+ return item;
150
+ });
151
+ }
152
+
153
+ function renderDialog(task) {
154
+ const preservedResults = state.selectedTask?.id === task.id
155
+ ? state.selectedTask.results
156
+ : null;
157
+ const detailedTask = {
158
+ ...task,
159
+ results: task.results ?? preservedResults ?? [],
160
+ };
161
+ state.selectedTask = detailedTask;
124
162
  elements.dialogProject.textContent = task.project.name;
125
163
  elements.dialogTitle.textContent = task.title;
126
- elements.dialogSummary.textContent = task.lastResult?.summary
127
- ?? "No semantic result has been reported yet.";
164
+ elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
128
165
  elements.dialogInstruction.textContent = task.instruction;
129
166
  elements.copyThreadId.disabled = !task.threadId;
130
167
  elements.dialogMetadata.replaceChildren(
@@ -142,7 +179,28 @@ function openDialog(task) {
142
179
  )),
143
180
  ...detailRow("Updated by", task.updatedBy),
144
181
  );
182
+ }
183
+
184
+ async function openDialog(task) {
185
+ const requestGeneration = ++detailRequestGeneration;
186
+ renderDialog(task);
145
187
  if (!elements.dialog.open) elements.dialog.showModal();
188
+ try {
189
+ const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
190
+ if (!response.ok) throw new Error("Task details are unavailable.");
191
+ const detail = await response.json();
192
+ if (
193
+ requestGeneration === detailRequestGeneration
194
+ && state.selectedTask?.id === task.id
195
+ && elements.dialog.open
196
+ ) {
197
+ renderDialog(detail.task);
198
+ }
199
+ } catch {
200
+ if (state.selectedTask?.id === task.id) {
201
+ showMessage("Task result history is temporarily unavailable.");
202
+ }
203
+ }
146
204
  }
147
205
 
148
206
  function taskCard(task) {
@@ -87,8 +87,8 @@
87
87
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
88
88
  </div>
89
89
  <section>
90
- <h3>Result</h3>
91
- <p id="dialog-summary" class="preserve-lines"></p>
90
+ <h3>Result history</h3>
91
+ <div id="dialog-results" class="result-history"></div>
92
92
  </section>
93
93
  <section>
94
94
  <h3>Original instruction</h3>
@@ -117,6 +117,14 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
117
117
  .primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
118
118
  dialog section + section { margin-top: 24px; }
119
119
  .preserve-lines { white-space: pre-wrap; }
120
+ .result-history { display: grid; gap: 10px; }
121
+ .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
122
+ .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
123
+ .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
124
+ .result-history-item p { margin-bottom: 7px; }
125
+ .result-history-item p:last-child { margin-bottom: 0; }
126
+ .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
127
+ .result-history-empty { margin: 0; }
120
128
  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; }
121
129
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
122
130
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
package/src/dashboard.js CHANGED
@@ -136,6 +136,14 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
136
136
  boundedText(task.turnId, 512, `${name} turn ID`);
137
137
  boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
138
138
  boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
139
+ const results = task.results ?? [];
140
+ if (results.length > 10_000) {
141
+ throw new Error(`${name} has too many results for the dashboard`);
142
+ }
143
+ for (const [resultIndex, result] of results.entries()) {
144
+ boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
145
+ boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
146
+ }
139
147
  boundedText(task.project.name, 1_000, `${name} project name`);
140
148
  boundedText(task.project.path, 8_192, `${name} project path`);
141
149
  boundedText(task.project.description, 4_000, `${name} project description`);
@@ -148,6 +156,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
148
156
  }
149
157
  }
150
158
 
159
+ function taskListProjection(task) {
160
+ const { results: _results, ...projection } = task;
161
+ return projection;
162
+ }
163
+
151
164
  export class DashboardMonitor extends EventEmitter {
152
165
  constructor(workspace, {
153
166
  debounceMs = 75,
@@ -197,7 +210,7 @@ export class DashboardMonitor extends EventEmitter {
197
210
  generatedAt: new Date().toISOString(),
198
211
  healthy: !this.unhealthy,
199
212
  tasks: this.tasks.map((task) => ({
200
- ...task,
213
+ ...taskListProjection(task),
201
214
  meaningfulUpdatedAt: new Date(
202
215
  meaningfulUpdateTime(task, this.observedUpdateTimes),
203
216
  ).toISOString(),
@@ -510,6 +523,22 @@ export async function createDashboardServer({
510
523
  return;
511
524
  }
512
525
 
526
+ const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
527
+ if (detailMatch && (method === "GET" || method === "HEAD")) {
528
+ const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
529
+ if (!task) {
530
+ sendJson(response, 404, { message: "Task not found." });
531
+ return;
532
+ }
533
+ if (method === "HEAD") {
534
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
535
+ response.end();
536
+ } else {
537
+ sendJson(response, 200, { schemaVersion: 1, task });
538
+ }
539
+ return;
540
+ }
541
+
513
542
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
514
543
  if (taskMatch && method === "POST") {
515
544
  if (request.headers.origin !== allowedOrigin) {
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)]),
24
+ schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
25
25
  id: z.string(),
26
26
  project: projectSchema,
27
27
  title: z.string(),
@@ -33,6 +33,12 @@ const taskSchema = z.object({
33
33
  turnId: z.string().nullable(),
34
34
  updatedAt: z.string(),
35
35
  updatedBy: z.enum(["dispatcher", "mcp"]),
36
+ results: z.array(z.object({
37
+ status: z.enum(["needs_input", "completed", "failed"]),
38
+ summary: z.string(),
39
+ turnId: z.string().nullable(),
40
+ updatedAt: z.string(),
41
+ })),
36
42
  lastResult: z.object({
37
43
  status: z.enum(["needs_input", "completed", "failed"]),
38
44
  summary: z.string(),
package/src/workspace.js CHANGED
@@ -38,8 +38,9 @@ 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 = 5;
42
- const PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 6;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 5;
43
+ const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
43
44
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
44
45
  const PROJECT_FIELDS = new Set([
45
46
  "name",
@@ -63,7 +64,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
63
64
  "updatedAt",
64
65
  "updatedBy",
65
66
  ]);
66
- const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
67
+ const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
68
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
67
69
  const RECORD_DISPATCH_FIELDS = new Set([
68
70
  "id",
69
71
  "project",
@@ -75,7 +77,7 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
75
77
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
76
78
  const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
77
79
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
78
- const LAST_RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
80
+ const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
79
81
 
80
82
  function requireExactFields(value, fields, name) {
81
83
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -221,7 +223,9 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
221
223
  if (dispatches.length === 0) return;
222
224
  const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
223
225
  const content = await readFile(dispatchPath, "utf8");
224
- const appended = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
226
+ const appended = dispatches
227
+ .map((dispatch) => `${JSON.stringify(schema6Task(dispatch))}\n`)
228
+ .join("");
225
229
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
226
230
  }
227
231
 
@@ -679,7 +683,8 @@ export async function removeProject(workspaceRoot, name) {
679
683
 
680
684
  async function validateDispatchShape(dispatch, name = "task") {
681
685
  const supportedVersions = [
682
- PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION,
686
+ FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
687
+ PREVIOUS_TASK_SCHEMA_VERSION,
683
688
  CURRENT_TASK_SCHEMA_VERSION,
684
689
  ];
685
690
  if (!supportedVersions.includes(dispatch?.schemaVersion)) {
@@ -687,9 +692,11 @@ async function validateDispatchShape(dispatch, name = "task") {
687
692
  }
688
693
  requireExactFields(
689
694
  dispatch,
690
- dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
695
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
691
696
  ? DISPATCH_FIELDS
692
- : STATEFUL_DISPATCH_FIELDS,
697
+ : dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
698
+ ? SCHEMA_5_DISPATCH_FIELDS
699
+ : STATEFUL_DISPATCH_FIELDS,
693
700
  name,
694
701
  );
695
702
  const id = requireSafeId(dispatch.id, `${name}.id`);
@@ -700,38 +707,48 @@ async function validateDispatchShape(dispatch, name = "task") {
700
707
  });
701
708
  const turnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
702
709
  const updatedAt = requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`);
703
- let lastResult = null;
704
- if (dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
705
- if (dispatch.lastResult !== null) {
706
- requireExactFields(dispatch.lastResult, LAST_RESULT_FIELDS, `${name}.lastResult`);
707
- lastResult = {
710
+ const normalizeResult = (result, resultName) => {
711
+ requireExactFields(result, RESULT_FIELDS, resultName);
712
+ const normalizedResult = {
708
713
  status: requireEnum(
709
- dispatch.lastResult.status,
714
+ result.status,
710
715
  RESULT_STATUSES,
711
- `${name}.lastResult.status`,
716
+ `${resultName}.status`,
712
717
  ),
713
718
  summary: optionalString(
714
- dispatch.lastResult.summary,
715
- `${name}.lastResult.summary`,
719
+ result.summary,
720
+ `${resultName}.summary`,
716
721
  { maxLength: MAX_RESULT_SUMMARY_LENGTH },
717
722
  ),
718
723
  turnId: optionalString(
719
- dispatch.lastResult.turnId,
720
- `${name}.lastResult.turnId`,
724
+ result.turnId,
725
+ `${resultName}.turnId`,
721
726
  { maxLength: 256 },
722
727
  ),
723
728
  updatedAt: requireTimestamp(
724
- dispatch.lastResult.updatedAt,
725
- `${name}.lastResult.updatedAt`,
729
+ result.updatedAt,
730
+ `${resultName}.updatedAt`,
726
731
  ),
727
- };
728
- if (lastResult.summary === null) {
729
- throw new Error(`${name}.lastResult.summary must be a non-empty string`);
730
- }
732
+ };
733
+ if (normalizedResult.summary === null) {
734
+ throw new Error(`${resultName}.summary must be a non-empty string`);
735
+ }
736
+ return normalizedResult;
737
+ };
738
+ let results = [];
739
+ if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
740
+ if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
741
+ results = dispatch.results.map((result, index) => (
742
+ normalizeResult(result, `${name}.results[${index}]`)
743
+ ));
744
+ } else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
745
+ if (dispatch.lastResult !== null) {
746
+ results = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
731
747
  }
732
748
  } else if (RESULT_STATUSES.has(status)) {
733
- lastResult = { status, summary, turnId, updatedAt };
749
+ results = [{ status, summary, turnId, updatedAt }];
734
750
  }
751
+ const lastResult = results.at(-1) ?? null;
735
752
  const normalized = {
736
753
  schemaVersion: dispatch.schemaVersion,
737
754
  id,
@@ -747,22 +764,25 @@ async function validateDispatchShape(dispatch, name = "task") {
747
764
  turnId,
748
765
  updatedAt,
749
766
  updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
767
+ results,
750
768
  lastResult,
751
769
  };
752
770
  const isSelfLinkingRecord = normalized.threadId !== null
753
771
  && parseTaskChefMarker(normalized.instruction) === normalized.id;
754
772
  if (isSelfLinkingRecord) {
755
773
  if (normalized.turnId !== null) {
756
- normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
774
+ normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
757
775
  }
758
- if (normalized.lastResult?.turnId != null) {
759
- normalizeCodexThreadId(
760
- normalized.lastResult.turnId,
761
- `${name}.lastResult.turnId`,
762
- );
776
+ for (const [index, result] of normalized.results.entries()) {
777
+ if (result.turnId != null) {
778
+ result.turnId = normalizeCodexThreadId(
779
+ result.turnId,
780
+ `${name}.results[${index}].turnId`,
781
+ );
782
+ }
763
783
  }
764
784
  }
765
- if (normalized.schemaVersion >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION) {
785
+ if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
766
786
  if (normalized.threadId === null) {
767
787
  const isLinkPending = normalized.status === "working"
768
788
  && normalized.summary === null
@@ -782,8 +802,9 @@ async function validateDispatchShape(dispatch, name = "task") {
782
802
  if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
783
803
  throw new Error(`${name}.turnId is required for a linked semantic state`);
784
804
  }
785
- if (normalized.lastResult !== null && normalized.lastResult.turnId === null) {
786
- throw new Error(`${name}.lastResult.turnId is required for a linked result`);
805
+ const resultWithoutTurn = normalized.results.findIndex((result) => result.turnId === null);
806
+ if (resultWithoutTurn !== -1) {
807
+ throw new Error(`${name}.results[${resultWithoutTurn}].turnId is required for a linked result`);
787
808
  }
788
809
  }
789
810
  }
@@ -793,7 +814,7 @@ async function validateDispatchShape(dispatch, name = "task") {
793
814
  if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
794
815
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
795
816
  }
796
- if (normalized.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
817
+ if (normalized.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION) {
797
818
  if (RESULT_STATUSES.has(normalized.status)) {
798
819
  if (
799
820
  normalized.lastResult === null
@@ -829,6 +850,34 @@ async function validateDispatchShape(dispatch, name = "task") {
829
850
  throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
830
851
  }
831
852
  }
853
+ const seenResultTurns = new Set();
854
+ const nullTurnKey = Symbol("null turn");
855
+ for (const [index, result] of normalized.results.entries()) {
856
+ const turnKey = result.turnId ?? nullTurnKey;
857
+ if (seenResultTurns.has(turnKey)) {
858
+ throw new Error(`${name}.results contains duplicate turnId: ${result.turnId ?? "null"}`);
859
+ }
860
+ seenResultTurns.add(turnKey);
861
+ if (Date.parse(result.updatedAt) < Date.parse(normalized.createdAt)) {
862
+ throw new Error(`${name}.results[${index}].updatedAt must not be earlier than createdAt`);
863
+ }
864
+ if (Date.parse(result.updatedAt) > Date.parse(normalized.updatedAt)) {
865
+ throw new Error(`${name}.results[${index}].updatedAt must not be later than updatedAt`);
866
+ }
867
+ if (
868
+ index > 0
869
+ && Date.parse(result.updatedAt) < Date.parse(normalized.results[index - 1].updatedAt)
870
+ ) {
871
+ throw new Error(`${name}.results must be ordered by updatedAt`);
872
+ }
873
+ if (
874
+ isSelfLinkingRecord
875
+ && index > 0
876
+ && result.turnId <= normalized.results[index - 1].turnId
877
+ ) {
878
+ throw new Error(`${name}.results must be ordered by turnId`);
879
+ }
880
+ }
832
881
  if (
833
882
  Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
834
883
  ) {
@@ -911,6 +960,70 @@ export async function parseTaskLogContent(workspaceRoot, content) {
911
960
  return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
912
961
  }
913
962
 
963
+ function schema6Task(dispatch, patch = {}) {
964
+ const { lastResult: _lastResult, ...persisted } = dispatch;
965
+ return {
966
+ ...persisted,
967
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
968
+ results: dispatch.results ?? [],
969
+ ...patch,
970
+ };
971
+ }
972
+
973
+ export async function migrateTaskLog(workspaceRoot, {
974
+ now = () => new Date().toISOString(),
975
+ writeTaskLog = writeTextAtomic,
976
+ } = {}) {
977
+ const root = await realpath(path.resolve(workspaceRoot));
978
+ return withWorkspaceLock(root, async () => {
979
+ const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
980
+ if (!(await managedRegularFileExists(dispatchPath))) {
981
+ throw new Error(`task log does not exist: ${dispatchPath}`);
982
+ }
983
+ const original = await readFile(dispatchPath, "utf8");
984
+ const records = await parseDispatchRecordsUnlocked(root, original);
985
+ const migratedCount = records.filter(
986
+ (record) => record.raw.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION,
987
+ ).length;
988
+ if (migratedCount === 0) {
989
+ return {
990
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
991
+ action: "unchanged",
992
+ taskCount: records.length,
993
+ migratedCount: 0,
994
+ backupPath: null,
995
+ };
996
+ }
997
+ const lines = records.map((record) => JSON.stringify(schema6Task(record.normalized)));
998
+ const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
999
+ await parseDispatchRecordsUnlocked(root, migrated);
1000
+ const timestamp = requireTimestamp(now(), "migration timestamp")
1001
+ .replaceAll(":", "-")
1002
+ .replaceAll(".", "-");
1003
+ const backupPath = `${dispatchPath}.pre-v6-${timestamp}-${randomUUID()}.bak`;
1004
+ await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1005
+ if (await readFile(backupPath, "utf8") !== original) {
1006
+ throw new Error(`task log backup validation failed: ${backupPath}`);
1007
+ }
1008
+ try {
1009
+ await writeTaskLog(dispatchPath, migrated);
1010
+ await parseDispatchRecordsUnlocked(root, await readFile(dispatchPath, "utf8"));
1011
+ } catch (error) {
1012
+ throw new Error(
1013
+ `task log migration failed after recovery backup ${backupPath}: ${error.message}`,
1014
+ { cause: error },
1015
+ );
1016
+ }
1017
+ return {
1018
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1019
+ action: "migrated",
1020
+ taskCount: records.length,
1021
+ migratedCount,
1022
+ backupPath,
1023
+ };
1024
+ });
1025
+ }
1026
+
914
1027
  export async function recordTask(workspaceRoot, input, { now } = {}) {
915
1028
  requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
916
1029
  const root = await realpath(path.resolve(workspaceRoot));
@@ -933,7 +1046,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
933
1046
  turnId: null,
934
1047
  updatedAt: createdAt,
935
1048
  updatedBy: "dispatcher",
936
- lastResult: null,
1049
+ results: [],
937
1050
  });
938
1051
  const existing = await readDispatchesUnlocked(root);
939
1052
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -975,13 +1088,11 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
975
1088
  throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
976
1089
  }
977
1090
  if (dispatch.threadId === durableThreadId) return dispatch;
978
- const canonical = await validateDispatchShape({
979
- ...dispatch,
980
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1091
+ const canonical = await validateDispatchShape(schema6Task(dispatch, {
981
1092
  threadId: durableThreadId,
982
1093
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
983
1094
  updatedBy: "mcp",
984
- });
1095
+ }));
985
1096
  const lines = records.map((record, recordIndex) => recordIndex === index
986
1097
  ? dispatchLineWithState(canonical, {})
987
1098
  : record.line);
@@ -1006,13 +1117,11 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1006
1117
  ))) {
1007
1118
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
1008
1119
  }
1009
- const linked = await validateDispatchShape({
1010
- ...dispatch,
1011
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1120
+ const linked = await validateDispatchShape(schema6Task(dispatch, {
1012
1121
  threadId: durableThreadId,
1013
1122
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1014
1123
  updatedBy: "mcp",
1015
- });
1124
+ }));
1016
1125
  const lines = records.map((record, recordIndex) => recordIndex === index
1017
1126
  ? dispatchLineWithState(linked, {})
1018
1127
  : record.line);
@@ -1022,11 +1131,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1022
1131
  }
1023
1132
 
1024
1133
  function dispatchLineWithState(dispatch, patch) {
1025
- return JSON.stringify({
1026
- ...dispatch,
1027
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1028
- ...patch,
1029
- });
1134
+ return JSON.stringify(schema6Task(dispatch, patch));
1030
1135
  }
1031
1136
 
1032
1137
  function normalizeTaskStateInput(input, { allowWorking }) {
@@ -1108,7 +1213,7 @@ async function reportTaskStateInternal(
1108
1213
  if (!compatibilityAlias) {
1109
1214
  const rawSchemaVersion = records[index].raw.schemaVersion;
1110
1215
  const hasCurrentMarker = rawSchemaVersion
1111
- >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION
1216
+ >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION
1112
1217
  && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
1113
1218
  const isFreshCreationFailure = hasCurrentMarker
1114
1219
  && dispatch.status === "working"
@@ -1147,31 +1252,24 @@ async function reportTaskStateInternal(
1147
1252
  }
1148
1253
  }
1149
1254
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1150
- const updated = await validateDispatchShape({
1151
- ...dispatch,
1152
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1255
+ const updated = await validateDispatchShape(schema6Task(dispatch, {
1153
1256
  status,
1154
1257
  summary: null,
1155
1258
  turnId: stateTurnId,
1156
1259
  updatedAt,
1157
1260
  updatedBy: "mcp",
1158
- lastResult: dispatch.lastResult,
1159
- });
1261
+ }));
1160
1262
  const lines = records.map((record, recordIndex) => recordIndex === index
1161
1263
  ? dispatchLineWithState(updated, {})
1162
1264
  : record.line);
1163
1265
  await writeDispatchLinesAtomic(root, lines);
1164
1266
  return updated;
1165
1267
  }
1166
- if (
1167
- dispatch.status === status
1168
- && dispatch.turnId === stateTurnId
1169
- && dispatch.summary === summary
1170
- && sameLastResult(dispatch.lastResult, { status, summary, turnId: stateTurnId })
1171
- ) {
1268
+ const priorTurnResult = dispatch.results.find((result) => result.turnId === stateTurnId);
1269
+ if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
1172
1270
  return dispatch;
1173
1271
  }
1174
- if (dispatch.turnId === stateTurnId && dispatch.status !== "working") {
1272
+ if (priorTurnResult || (dispatch.turnId === stateTurnId && dispatch.status !== "working")) {
1175
1273
  throw new Error(`task turn already has a different semantic result: ${id}`);
1176
1274
  }
1177
1275
  if (compatibilityAlias) {
@@ -1190,16 +1288,14 @@ async function reportTaskStateInternal(
1190
1288
  }
1191
1289
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1192
1290
  const lastResult = { status, summary, turnId: stateTurnId, updatedAt };
1193
- const candidate = {
1194
- ...dispatch,
1195
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1291
+ const candidate = schema6Task(dispatch, {
1196
1292
  status,
1197
1293
  summary,
1198
1294
  turnId: stateTurnId,
1199
1295
  updatedAt,
1200
1296
  updatedBy: "mcp",
1201
- lastResult,
1202
- };
1297
+ results: [...dispatch.results, lastResult],
1298
+ });
1203
1299
  const updated = await validateDispatchShape(candidate);
1204
1300
  const lines = records.map((record, recordIndex) => recordIndex === index
1205
1301
  ? dispatchLineWithState(updated, {})