taskchef 7.9.1 → 7.11.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.9.1",
3
+ "version": "7.11.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
@@ -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 task snapshot per line (schema 7; schema 4/5/6 migration supported)
52
+ tasks.jsonl one task snapshot per line (schema 8; schema 4/5/6/7 migration supported)
53
53
  ```
54
54
 
55
55
  List or change routing targets conversationally:
@@ -123,7 +123,12 @@ appends a `turns` entry that pairs that request with a null result while working
123
123
  then fills the same entry with the semantic outcome. A follow-up therefore shows
124
124
  its own request with “In progress,” never the preceding turn's result. Returned
125
125
  tasks still derive `results` and `lastResult` as compatibility projections.
126
- TaskChef does not store transcripts, hidden reasoning, or non-semantic events.
126
+ If Codex crashes, an MCP call is lost, or the app restarts before that terminal
127
+ report, the next newer `working` report atomically marks the unfinished turn
128
+ `interrupted` and appends the new active turn. `interrupted` is TaskChef-authored
129
+ timeline evidence, not semantic `failed`, and it never enters `results` or
130
+ `lastResult`. TaskChef stores only a fixed interruption summary; it does not
131
+ store transcripts, hidden reasoning, crash output, or other non-semantic events.
127
132
 
128
133
  Delegated tasks created by earlier TaskChef versions remain compatible: their
129
134
  inline executor protocol still parses, self-links, and may use the deprecated
@@ -175,7 +180,7 @@ taskchef dashboard --port 3211
175
180
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
176
181
  linked Codex tasks. List snapshots and SSE events carry only the latest
177
182
  request/result pair; opening task details fetches the full newest-first activity
178
- timeline.
183
+ timeline, including clearly labeled interrupted turns.
179
184
  The header shows the running TaskChef package version reported by the same
180
185
  bounded health identity used for compatible-listener checks.
181
186
  Task and result times are relative through 29 days (with minute detail for the
@@ -205,6 +210,8 @@ no task data, credentials, environment variables, process control, or secrets.
205
210
 
206
211
  ![Task detail activity timeline](docs/images/result-history-dashboard.jpg)
207
212
 
213
+ ![Interrupted turn followed by active recovery](docs/images/interrupted-turn-recovery.jpg)
214
+
208
215
  ## Common recovery
209
216
 
210
217
  Check the managed workspace:
@@ -218,8 +225,8 @@ taskchef doctor
218
225
 
219
226
  `doctor` is read-only. `workspace init` creates missing files and refreshes
220
227
  managed instructions. `workspace migrate` explicitly upgrades supported schema
221
- 4/5/6 task lines to schema 7 under the workspace lock. It validates the complete
222
- source and converted log before writing, creates an exclusive `tasks.jsonl.pre-v7-*.bak`
228
+ 4/5/6/7 task lines to schema 8 under the workspace lock. It validates the complete
229
+ source and converted log before writing, creates an exclusive `tasks.jsonl.pre-v8-*.bak`
223
230
  backup, atomically replaces the log, validates the result, and becomes an
224
231
  idempotent no-op after migration. If replacement fails, the original remains
225
232
  or the reported backup can be restored; unsupported or invalid input is rejected
@@ -230,7 +237,7 @@ executor so its first action can retry `link_task`. Do not guess an identity
230
237
  or edit `tasks.jsonl`. If native task creation failed, the record is retained
231
238
  as `failed` with null thread and turn IDs.
232
239
 
233
- Schemas other than 4, 5, 6, and 7 remain unsupported. Retain such a workspace
240
+ Schemas other than 4, 5, 6, 7, and 8 remain unsupported. Retain such a workspace
234
241
  unchanged and create a current workspace; the migration command deliberately
235
242
  does not guess how to convert unknown formats.
236
243
 
@@ -162,8 +162,11 @@ backends have different verification or experimental status.
162
162
 
163
163
  **Fact:** Persisted statuses are `working`, `needs_input`, `completed`, and
164
164
  `failed`. Executors report
165
- `working` at turn start and a semantic state before ending. Schema 5 preserves
166
- the last concise semantic result separately while a newer turn is working.
165
+ `working` at turn start and a semantic state before ending. Schema 8 preserves
166
+ the paired turn timeline and semantic-only compatibility results while a newer
167
+ turn is working. If a terminal report is lost, the next valid working start
168
+ closes the predecessor with a timeline-only `interrupted` outcome rather than
169
+ semantic `failed`.
167
170
  `needs_input` is reserved for a real semantic decision, not a native approval
168
171
  prompt.
169
172
 
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
- | **Turn timeline** | The ordered collection pairing each turn's concise request summary with its eventual semantic result. |
26
+ | **Turn timeline** | The ordered collection pairing each turn's concise request summary with its semantic result, TaskChef-generated interruption outcome, or current in-progress state. |
27
+ | **Interrupted turn** | A formerly active turn that lacked a terminal report when a newer valid turn started; TaskChef closes it with the fixed timeline-only `interrupted` outcome. |
27
28
  | **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
28
29
  | **Current turn ID** | The canonical Codex UUIDv7 returned by an exact native read of the linked executor for the turn being reported. |
29
30
  | **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
@@ -68,9 +69,9 @@ Names and paths MUST be unique. Git projects MUST be exact Git roots.
68
69
  Repository URLs MUST canonicalize to `https://github.com/<owner>/<repository>`
69
70
  and be case-insensitively deduplicated.
70
71
 
71
- `tasks.jsonl` MUST contain zero or more newline-terminated schema-4, schema-5,
72
- schema-6, or schema-7 records, one per line. Schemas 4 through 6 are supported
73
- migration/read formats; every new record and state mutation MUST write schema 7. Other schemas
72
+ `tasks.jsonl` MUST contain zero or more newline-terminated schema-4 through
73
+ schema-8 records, one per line. Schemas 4 through 7 are supported
74
+ migration/read formats; every new record and state mutation MUST write schema 8. Other schemas
74
75
  or unsupported fields MUST be rejected without conversion.
75
76
  Reads and writes MUST reject symlinked managed files. Mutations
76
77
  MUST hold the shared workspace lock and replace state atomically; read-only
@@ -82,7 +83,7 @@ Every record MUST contain exactly these fields:
82
83
 
83
84
  | Field | Contract |
84
85
  | --- | --- |
85
- | `schemaVersion` | Integer `7`; schema-4/5/6 records remain readable until explicit migration or their next mutation. |
86
+ | `schemaVersion` | Integer `8`; schema-4/5/6/7 records remain readable until explicit migration or their next mutation. |
86
87
  | `id` | Unique safe TaskChef ID; delegation uses a lowercase full UUID. |
87
88
  | `project` | Immutable configured-project snapshot. |
88
89
  | `title` | Non-empty display title. |
@@ -94,12 +95,14 @@ Every record MUST contain exactly these fields:
94
95
  | `turnId` | Null before turn reporting; otherwise the current reported turn. Linked MCP journeys use a canonical Codex UUIDv7. |
95
96
  | `updatedAt` | ISO 8601 timestamp not earlier than `createdAt` or the prior `updatedAt`; clock rollback cannot backdate a transition. |
96
97
  | `updatedBy` | `dispatcher` or `mcp`. |
97
- | `turns` | Ordered oldest-first array of `{turnId, requestSummary, startedAt, result}`. `requestSummary` is null only for migrated/compatibility turns; `result` is null only for the latest working turn or is `{status, summary, updatedAt}`. Canonical self-linking turn IDs are unique. A migrated low-level opaque record may retain one final reused ID to represent its legacy completed-result-plus-working-state ambiguity. |
98
+ | `turns` | Ordered oldest-first array of `{turnId, requestSummary, startedAt, result}`. `requestSummary` is null only for migrated/compatibility turns. `result` is null only for the latest working turn, a semantic `{status, summary, updatedAt}` result, or the TaskChef-generated `{status: "interrupted", summary: "Turn interrupted before a terminal report.", updatedAt}` outcome. Canonical self-linking turn IDs are unique. A migrated low-level opaque record may retain one final reused ID to represent its legacy completed-result-plus-working-state ambiguity. |
98
99
 
99
100
  Returned Task objects MUST additionally expose `latestTurn` as null for an empty
100
- timeline or the final `turns` entry. They MUST derive `results` from completed
101
- turns and `lastResult` from the final derived result. These projections MUST NOT
102
- be persisted in schema 7 and remain compatibility aliases for existing callers.
101
+ timeline or the final `turns` entry. They MUST derive `results` only from
102
+ semantic `needs_input`, `completed`, and `failed` turn results and `lastResult`
103
+ from the final derived semantic result. Interrupted outcomes MUST be excluded.
104
+ These projections MUST NOT be persisted in schema 8 and remain compatibility
105
+ aliases for existing callers.
103
106
 
104
107
  Task IDs and non-null thread identities MUST be unique. The immutable intent
105
108
  fields MUST NOT change after recording.
@@ -129,7 +132,9 @@ the final link, preserving the delegate skill's immediate-return contract.
129
132
  8. Before ending, it MUST call `report_state` for the same working turn with a
130
133
  semantic status and concise summary.
131
134
  9. A follow-up MUST report `working` with its new current turn ID before work.
132
- It MUST NOT reuse a prior turn.
135
+ It MUST NOT reuse a prior turn. If the preceding turn is still unfinished,
136
+ TaskChef MUST atomically close it as `interrupted` before appending the new
137
+ working turn; the executor MUST NOT report semantic `failed` for recovery.
133
138
 
134
139
  If native creation fails after recording, the dispatcher MUST call
135
140
  `report_state` with `failed`, null thread/turn IDs, and a bounded summary.
@@ -238,7 +243,7 @@ new preparation values, though it writes no state.
238
243
 
239
244
  **Structured output:** `{ task: Task }`.
240
245
 
241
- The returned task has schema 7, `working`, null summary/turn/thread/latestTurn/lastResult,
246
+ The returned task has schema 8, `working`, null summary/turn/thread/latestTurn/lastResult,
242
247
  empty `turns` and derived `results` arrays,
243
248
  `updatedBy: dispatcher`, and equal creation/update timestamps. Duplicate IDs,
244
249
  unknown projects, malformed markers, and invalid input fail. Repeating a
@@ -283,16 +288,23 @@ marker, or ineligible state fails.
283
288
 
284
289
  **Structured output:** `{ task: Task }`.
285
290
 
286
- For a linked self-linking journey, `working` MUST identify a turn newer than
287
- the current turn and last semantic result. A semantic state MUST match the
288
- current working turn. Repeating an identical state is idempotent; conflicting
289
- or older state fails. A null-identity record accepts only a fresh executor
291
+ For a linked self-linking journey, a new `working` state MUST identify a turn
292
+ newer than the current turn and last semantic result. An exact retry of any
293
+ already-recorded working start MUST return the current task without mutation.
294
+ A semantic state MUST match the current working turn. Conflicting or stale
295
+ state fails. A null-identity record accepts only a fresh executor
290
296
  creation `failed` state with both IDs null. Starting work appends one turn with
291
- its request and a null result; the semantic report fills that same turn's result.
297
+ its request and a null result. When the previous latest turn is unfinished, the
298
+ same locked atomic rewrite MUST first fill it with the fixed TaskChef-generated
299
+ `interrupted` outcome. The semantic report fills the active turn's result.
292
300
  An identical retry for any settled turn returns success without an append. A
293
301
  different request or result for the same turn, a stale turn, or a semantic
294
302
  result that does not match the active working turn MUST fail.
295
303
 
304
+ `interrupted` MUST NOT be accepted as MCP input or projected as task `status`.
305
+ It MUST NOT alter `summary`, `results`, or `lastResult`, and its fixed summary
306
+ MUST contain no crash output, transcript, user text, or inferred failure cause.
307
+
296
308
  **Annotations:** `readOnlyHint: false`, `destructiveHint: true`,
297
309
  `openWorldHint: false`.
298
310
 
@@ -302,8 +314,8 @@ result that does not match the active working turn MUST fail.
302
314
  temporary compatibility alias. It implicitly accepts a fresh supplied turn and
303
315
  stores its semantic result in a request-unknown turn, including for supported schema-4/5/6 records and
304
316
  low-level opaque direct records. It does not accept `working`. New executor
305
- instructions MUST use `report_state`. Successful mutation upgrades schema 4/5/6
306
- to schema 7; unsupported schemas remain rejected.
317
+ instructions MUST use `report_state`. Successful mutation upgrades schema 4/5/6/7
318
+ to schema 8; unsupported schemas remain rejected.
307
319
 
308
320
  ## Reporting and dashboard
309
321
 
@@ -359,12 +371,13 @@ its displayed summary when present, event time, and missing-task state.
359
371
 
360
372
  ## Task-log migration
361
373
 
362
- `workspace migrate` MUST explicitly convert every supported schema-4/5/6 record
374
+ `workspace migrate` MUST explicitly convert every supported schema-4/5/6/7 record
363
375
  under the shared lock. Each legacy semantic result becomes a request-unknown
364
- completed turn; a newer working state becomes a final unfinished turn. Migration
365
- MUST validate the complete source and complete schema-7 candidate before changing the task log,
376
+ completed turn; a newer working state becomes a final unfinished turn, and a
377
+ schema-7 timeline is preserved losslessly. Migration MUST validate the complete
378
+ source and complete schema-8 candidate before changing the task log,
366
379
  create and read back an exclusive recovery backup, atomically replace the log,
367
- and validate the installed result. A fully schema-7 log MUST be an idempotent
380
+ and validate the installed result. A fully schema-8 log MUST be an idempotent
368
381
  no-op without another backup. Invalid/unsupported input MUST remain untouched;
369
382
  failures after backup creation MUST report the backup path and MUST never
370
383
  partially rewrite individual lines.
package/docs/workflows.md CHANGED
@@ -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-7 snapshot
92
+ W->>W: Lock, validate, append schema-8 snapshot
93
93
  W-->>M: working link-pending task
94
94
  M-->>D: task
95
95
  D->>C: Create executor with marked instruction
@@ -189,6 +189,43 @@ sequenceDiagram
189
189
  The executor contract therefore requires a new exact read on every follow-up;
190
190
  cached or inherited turn IDs are invalid.
191
191
 
192
+ ## Interrupted-turn recovery
193
+
194
+ A crash, MCP failure, app restart, or upgrade can leave the latest turn with a
195
+ null result. A newer valid `working` report is the durable recovery signal. The
196
+ workspace handles it inside the same lock and atomic replacement as every
197
+ other lifecycle mutation:
198
+
199
+ ```mermaid
200
+ sequenceDiagram
201
+ autonumber
202
+ participant E as Resumed executor
203
+ participant M as TaskChef MCP
204
+ participant W as workspace.js
205
+ participant F as tasks.jsonl
206
+ E->>M: report_state(..., turnB, working, requestB)
207
+ M->>W: reportTaskState()
208
+ W->>W: Acquire workspace lock and validate turnB > turnA
209
+ W->>W: Close unfinished turnA as interrupted
210
+ W->>W: Append turnB with requestB and null result
211
+ W->>F: One atomic schema-8 replacement
212
+ W-->>M: working task projected from turnB
213
+ M-->>E: Idempotent recovery success
214
+ E->>M: late semantic result for turnA
215
+ M->>W: Validate active turn and historical outcome
216
+ W-->>M: Reject stale result
217
+ M-->>E: Visible tool error
218
+ ```
219
+
220
+ The interrupted outcome uses only the fixed TaskChef-authored summary. It is
221
+ visible in CLI and detail timelines but excluded from semantic `results` and
222
+ `lastResult`. Compact dashboard cards therefore show request B with “In
223
+ progress.” Notification reconciliation observes one new working event and does
224
+ not manufacture a failed-result event. Exact retries of either working start
225
+ return the current snapshot without reopening or duplicating a turn. Concurrent
226
+ newer starts serialize under the lock, leaving one ordered timeline whose only
227
+ unfinished entry is the latest turn.
228
+
192
229
  ## Link-pending and failure paths
193
230
 
194
231
  A failed or interrupted link never authorizes substantive work. The record
@@ -306,14 +343,15 @@ all, and ordinary rerendering do not re-announce retained history. Toast action
306
343
  labels remain concise while `aria-describedby` connects the visible summary,
307
344
  event time, and missing-task explanation for assistive technology.
308
345
 
309
- ## Schema 4/5/6 migration
346
+ ## Schema 4/5/6/7 migration
310
347
 
311
348
  `taskchef workspace migrate` acquires the same workspace lock as lifecycle
312
349
  writers, validates the complete legacy log, converts schema-4/5/6 results into
313
350
  request-unknown completed turns and preserves a newer working turn, then validates the
314
351
  complete candidate. Before replacement it writes and reads back an exclusive
315
- `tasks.jsonl.pre-v7-*.bak` file. The task log is replaced atomically and
316
- validated again. A second run sees only schema 7 and returns unchanged without
352
+ `tasks.jsonl.pre-v8-*.bak` file. Schema-7 timelines are copied losslessly into
353
+ schema 8. The task log is replaced atomically and validated again. A second run
354
+ sees only schema 8 and returns unchanged without
317
355
  another backup. Unsupported or malformed input fails before backup/rewrite;
318
356
  after a later filesystem failure, the reported backup is the recovery source.
319
357
 
@@ -330,8 +368,9 @@ summary is cryptographically authenticated; this is a local single-user trust
330
368
  model. Managed files, instructions, project snapshots, MCP inputs, and dashboard
331
369
  requests are validated at every action boundary.
332
370
 
333
- Configuration schema 2 and task schemas 4, 5, 6, and 7 are accepted. Schemas
334
- 4/5/6 are read/migration compatibility until an explicit migration or lifecycle
335
- mutation upgrades each record to schema 7. Schema 7 persists `turns` and derives
336
- `results`, `lastResult`, and `latestTurn` for compact compatibility. Other schemas
371
+ Configuration schema 2 and task schemas 4, 5, 6, 7, and 8 are accepted. Schemas
372
+ 4/5/6/7 are read/migration compatibility until an explicit migration or lifecycle
373
+ mutation upgrades each record to schema 8. Schema 8 persists `turns`, including
374
+ timeline-only interrupted outcomes, and derives semantic-only `results` and
375
+ `lastResult` plus `latestTurn` for compact compatibility. Other schemas
337
376
  are rejected without rewrite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.9.1",
3
+ "version": "7.11.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -29,6 +29,7 @@
29
29
  "docs/images/dashboard-identity.jpg",
30
30
  "docs/images/notification-event-snapshots.jpg",
31
31
  "docs/images/result-history-dashboard.jpg",
32
+ "docs/images/interrupted-turn-recovery.jpg",
32
33
  "index.js",
33
34
  "mcp",
34
35
  "scripts/benchmark-dispatch-prepare.js",
@@ -35,6 +35,12 @@ Complete this lifecycle setup before substantive assignment work:
35
35
  current turn ID, `status: working`, an omitted or null result summary, and a
36
36
  concise `requestSummary` describing this turn's assignment or follow-up.
37
37
 
38
+ If the preceding TaskChef turn is still unfinished because its terminal report
39
+ was lost, this newer valid working report atomically records that predecessor
40
+ as interrupted and starts the current turn. Continue the real assignment from
41
+ the current request. Do not manufacture a semantic `failed` result for the old
42
+ turn and do not retry an old terminal report.
43
+
38
44
  If `CODEX_THREAD_ID`, exact native thread reading, or a required TaskChef tool
39
45
  is unavailable, or if linking or the working-state report fails, report the
40
46
  failure visibly and stop before substantive work. Retry on a later turn. Never
@@ -53,10 +59,11 @@ the current turn with one semantic status and a concise summary:
53
59
  A live native approval prompt is Codex state, not semantic `needs_input`; leave
54
60
  the approval live instead of storing it as a TaskChef result. Never invent or
55
61
  reuse a turn ID after a follow-up. If a final-report response is lost, an
56
- identical retry is safe only while the same turn remains current. On a later
57
- turn, run the start lifecycle with its new current turn ID and report that
58
- turn's actual outcome. Say reporting failures visibly instead of claiming a
59
- tracked outcome.
62
+ identical terminal retry is safe only while the same turn remains current. On
63
+ a later turn, run the start lifecycle with its new current turn ID; TaskChef
64
+ will preserve the predecessor as interrupted, and only the new turn may receive
65
+ a semantic result. Say reporting failures visibly instead of claiming a tracked
66
+ outcome.
60
67
 
61
68
  Request and result summaries must omit secrets, transcripts, raw command output, hidden reasoning,
62
69
  and unnecessary personal data. Identical lifecycle retries are safe; never
@@ -44,14 +44,18 @@ 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 7, treat `turns` as the ordered request/result timeline. Each turn
48
- pairs a bounded `requestSummary` with either one semantic `result` or null
49
- while work is in progress. `latestTurn` is the compact current pair.
47
+ 4. In schema 8, treat `turns` as the ordered request/result timeline. Each turn
48
+ pairs a bounded `requestSummary` with one semantic result, a TaskChef-generated
49
+ `interrupted` outcome, or null while work is in progress. Describe an
50
+ interrupted historical turn plainly as interrupted/abandoned, never failed.
51
+ `latestTurn` is the compact current pair.
50
52
  `results` and `lastResult` remain derived compatibility projections; do not
51
- pair the latest request with an earlier result. Treat a failed result with
53
+ expect interrupted outcomes in either projection and do not pair the latest
54
+ request with an earlier result. Treat a failed result with
52
55
  null thread and turn IDs as a fresh executor-creation failure. Schema 4/5/6
53
56
  snapshots normalize their semantic results into legacy turns with a null
54
- request summary without rewriting their log line.
57
+ request summary without rewriting their log line. Schema 7 timelines remain
58
+ readable but cannot contain the schema-8 interrupted outcome.
55
59
  No live read is possible or needed for that creation failure. When identity is certain and
56
60
  metadata says the thread is inactive, trust the latest semantic result by
57
61
  default in a broad overview unless a newer working state makes it historical.
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  clearNotifications,
3
3
  dismissNotification,
4
+ filterTasks,
4
5
  findCurrentTask,
5
- KNOWN_TASK_STATUSES,
6
6
  latestTurnPresentation,
7
7
  mergeProjectedTurns,
8
8
  nextDateFilterRefreshDelay,
@@ -10,8 +10,10 @@ import {
10
10
  notificationOpenLabel,
11
11
  notificationTitle,
12
12
  reconcileNotifications,
13
+ statusFilterCounts,
14
+ statusFilterText,
13
15
  taskStatusLabel,
14
- taskWithinDateFilter,
16
+ turnPresentation,
15
17
  } from "./state.js";
16
18
  import { openTaskFromControl } from "./actions.js";
17
19
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
@@ -237,11 +239,12 @@ function turnTimeline(task) {
237
239
  const header = document.createElement("div");
238
240
  header.className = "result-history-header";
239
241
  const status = document.createElement("span");
240
- const turnStatus = turn.result?.status ?? "working";
242
+ const presentation = turnPresentation(turn);
243
+ const turnStatus = presentation.status;
241
244
  status.className = `status status-${turnStatus}`;
242
245
  status.textContent = turnStatus.replaceAll("_", " ");
243
246
  const turnKey = turn.turnId ?? `no-turn:${index}`;
244
- const timestamp = timestampControl(turn.result?.updatedAt ?? turn.startedAt, {
247
+ const timestamp = timestampControl(presentation.updatedAt, {
245
248
  accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
246
249
  key: `detail:${task.id}:turn:${turnKey}`,
247
250
  });
@@ -255,7 +258,7 @@ function turnTimeline(task) {
255
258
  resultLabel.textContent = "Result";
256
259
  const result = document.createElement("p");
257
260
  result.className = "preserve-lines";
258
- result.textContent = turn.result?.summary ?? "In progress";
261
+ result.textContent = presentation.summary;
259
262
  const turnMetadata = document.createElement("p");
260
263
  turnMetadata.className = "result-history-turn";
261
264
  turnMetadata.textContent = turn.turnId
@@ -386,20 +389,43 @@ function taskCard(task) {
386
389
 
387
390
  function render() {
388
391
  const project = elements.projectFilter.value;
389
- const status = elements.statusFilter.value;
392
+ const status = elements.statusFilter.querySelector("input:checked")?.value ?? "";
390
393
  const date = elements.dateFilter.value;
391
- const visible = state.tasks.filter((task) =>
392
- (!project || task.project.name === project)
393
- && (!status || taskStatusLabel(task) === status)
394
- && taskWithinDateFilter(task, date));
394
+ const now = Date.now();
395
+ const contextualTasks = filterTasks(state.tasks, { project, date, now });
396
+ const visible = filterTasks(contextualTasks, { status, now });
397
+ const counts = statusFilterCounts(state.tasks, { project, date, now });
398
+ for (const label of elements.statusFilter.querySelectorAll("[data-status-label]")) {
399
+ const value = label.dataset.statusLabel;
400
+ label.textContent = statusFilterText(value, counts[value]);
401
+ }
395
402
  elements.taskList.replaceChildren(...visible.map(taskCard));
396
403
  elements.emptyState.hidden = visible.length > 0;
397
404
  elements.taskCount.textContent = `${visible.length} of ${state.tasks.length} task${state.tasks.length === 1 ? "" : "s"}`;
398
405
  clearTimeout(dateRefreshTimer);
399
- const refreshDelay = nextDateFilterRefreshDelay(visible, date);
406
+ const refreshDelay = nextDateFilterRefreshDelay(contextualTasks, date, now);
400
407
  dateRefreshTimer = refreshDelay === null ? null : setTimeout(render, refreshDelay);
401
408
  }
402
409
 
410
+ function selectStatusFromKeyboard(event) {
411
+ if (event.target?.name !== "status-filter") return;
412
+ const inputs = [...elements.statusFilter.querySelectorAll('input[name="status-filter"]')];
413
+ const currentIndex = inputs.indexOf(event.target);
414
+ if (currentIndex < 0) return;
415
+ let nextIndex = currentIndex;
416
+ if (["ArrowRight", "ArrowDown"].includes(event.key)) nextIndex = (currentIndex + 1) % inputs.length;
417
+ else if (["ArrowLeft", "ArrowUp"].includes(event.key)) {
418
+ nextIndex = (currentIndex - 1 + inputs.length) % inputs.length;
419
+ } else if (event.key === "Home") nextIndex = 0;
420
+ else if (event.key === "End") nextIndex = inputs.length - 1;
421
+ else if (![" ", "Enter"].includes(event.key)) return;
422
+ event.preventDefault();
423
+ for (const input of inputs) input.checked = false;
424
+ inputs[nextIndex].checked = true;
425
+ inputs[nextIndex].focus();
426
+ render();
427
+ }
428
+
403
429
  function applySnapshot(snapshot) {
404
430
  const reconciled = reconcileNotifications({
405
431
  initialized: state.initialized,
@@ -422,11 +448,6 @@ function applySnapshot(snapshot) {
422
448
  [...new Set(state.tasks.map((task) => task.project.name))].sort(),
423
449
  "All projects",
424
450
  );
425
- replaceOptions(
426
- elements.statusFilter,
427
- [...new Set([...KNOWN_TASK_STATUSES, ...state.tasks.map(taskStatusLabel)])],
428
- "All statuses",
429
- );
430
451
  if (state.selectedTask) {
431
452
  const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
432
453
  if (updated && elements.dialog.open) openDialog(updated);
@@ -455,6 +476,7 @@ events.addEventListener("dashboard-error", (event) => {
455
476
 
456
477
  elements.projectFilter.addEventListener("change", render);
457
478
  elements.statusFilter.addEventListener("change", render);
479
+ elements.statusFilter.addEventListener("keydown", selectStatusFromKeyboard);
458
480
  elements.dateFilter.addEventListener("change", render);
459
481
  elements.dismissDashboardMessage.addEventListener("click", () => {
460
482
  elements.dashboardMessage.hidden = true;
@@ -35,12 +35,31 @@
35
35
  <option value="">All projects</option>
36
36
  </select>
37
37
  </label>
38
- <label>
39
- Status
40
- <select id="status-filter">
41
- <option value="">All statuses</option>
42
- </select>
43
- </label>
38
+ <fieldset class="status-filter-fieldset">
39
+ <legend>Status</legend>
40
+ <div id="status-filter" class="status-filter-options">
41
+ <label class="status-filter-option">
42
+ <input type="radio" name="status-filter" value="" checked>
43
+ <span data-status-label="">All</span>
44
+ </label>
45
+ <label class="status-filter-option">
46
+ <input type="radio" name="status-filter" value="working">
47
+ <span data-status-label="working">Working</span>
48
+ </label>
49
+ <label class="status-filter-option">
50
+ <input type="radio" name="status-filter" value="needs input">
51
+ <span data-status-label="needs input">Needs input</span>
52
+ </label>
53
+ <label class="status-filter-option">
54
+ <input type="radio" name="status-filter" value="completed">
55
+ <span data-status-label="completed">Completed</span>
56
+ </label>
57
+ <label class="status-filter-option">
58
+ <input type="radio" name="status-filter" value="failed">
59
+ <span data-status-label="failed">Failed</span>
60
+ </label>
61
+ </div>
62
+ </fieldset>
44
63
  <label>
45
64
  Updated
46
65
  <select id="date-filter">
@@ -1,10 +1,17 @@
1
1
  export const MAX_NOTIFICATIONS = 50;
2
+ const INTERRUPTED_TURN_SUMMARY = "Turn interrupted before a terminal report.";
2
3
  export const KNOWN_TASK_STATUSES = [
3
4
  "working",
4
5
  "needs input",
5
6
  "completed",
6
7
  "failed",
7
- "unresolved",
8
+ ];
9
+ export const STATUS_FILTERS = [
10
+ { value: "", label: "All" },
11
+ { value: "working", label: "Working" },
12
+ { value: "needs input", label: "Needs input" },
13
+ { value: "completed", label: "Completed" },
14
+ { value: "failed", label: "Failed" },
8
15
  ];
9
16
 
10
17
  const NOTIFICATION_TITLES = new Map([
@@ -49,6 +56,15 @@ export function latestTurnPresentation(task) {
49
56
  };
50
57
  }
51
58
 
59
+ export function turnPresentation(turn) {
60
+ const result = turn.result ?? null;
61
+ return {
62
+ status: result?.status ?? "working",
63
+ summary: result?.summary ?? "In progress",
64
+ updatedAt: result?.updatedAt ?? turn.startedAt,
65
+ };
66
+ }
67
+
52
68
  export function mergeProjectedTurns(task, preservedTurns = []) {
53
69
  if (Array.isArray(task.turns)) return task.turns;
54
70
  if (!task.latestTurn) return preservedTurns;
@@ -57,6 +73,20 @@ export function mergeProjectedTurns(task, preservedTurns = []) {
57
73
  if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
58
74
  turns[lastIndex] = task.latestTurn;
59
75
  } else {
76
+ if (
77
+ task.schemaVersion === 8
78
+ && lastIndex >= 0
79
+ && turns[lastIndex].result === null
80
+ ) {
81
+ turns[lastIndex] = {
82
+ ...turns[lastIndex],
83
+ result: {
84
+ status: "interrupted",
85
+ summary: INTERRUPTED_TURN_SUMMARY,
86
+ updatedAt: task.latestTurn.startedAt,
87
+ },
88
+ };
89
+ }
60
90
  turns.push(task.latestTurn);
61
91
  }
62
92
  return turns;
@@ -74,6 +104,35 @@ export function taskWithinDateFilter(task, filter, now = Date.now()) {
74
104
  return Number.isFinite(meaningfulTime) && meaningfulTime >= now - windowMs;
75
105
  }
76
106
 
107
+ export function filterTasks(
108
+ tasks,
109
+ { project = "", status = "", date = "all", now = Date.now() } = {},
110
+ ) {
111
+ return tasks.filter((task) =>
112
+ (!project || task.project.name === project)
113
+ && (!status || taskStatusLabel(task) === status)
114
+ && taskWithinDateFilter(task, date, now));
115
+ }
116
+
117
+ export function statusFilterCounts(
118
+ tasks,
119
+ { project = "", date = "all", now = Date.now() } = {},
120
+ ) {
121
+ const contextualTasks = filterTasks(tasks, { project, date, now });
122
+ return Object.fromEntries(STATUS_FILTERS.map(({ value }) => [
123
+ value,
124
+ value
125
+ ? contextualTasks.filter((task) => taskStatusLabel(task) === value).length
126
+ : contextualTasks.length,
127
+ ]));
128
+ }
129
+
130
+ export function statusFilterText(value, count) {
131
+ const option = STATUS_FILTERS.find((filter) => filter.value === value);
132
+ if (!option) return "";
133
+ return count > 0 ? `${option.label} (${count})` : option.label;
134
+ }
135
+
77
136
  export function nextDateFilterRefreshDelay(tasks, filter, now = Date.now()) {
78
137
  const windowMs = DATE_WINDOWS_MS.get(filter);
79
138
  if (windowMs === null || windowMs === undefined) return null;
@@ -70,6 +70,15 @@ main { padding-block: 28px 80px; }
70
70
 
71
71
  label { display: grid; gap: 6px; color: var(--muted); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
72
72
  select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
73
+ .status-filter-fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
74
+ .status-filter-fieldset legend { margin-bottom: 6px; padding: 0; color: var(--muted); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
75
+ .status-filter-options { display: flex; flex-wrap: wrap; gap: 6px; }
76
+ .status-filter-option { position: relative; display: block; color: var(--text); font-size: 0.82rem; letter-spacing: 0; text-transform: none; cursor: pointer; }
77
+ .status-filter-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
78
+ .status-filter-option span { display: block; padding: 7px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); white-space: nowrap; }
79
+ .status-filter-option:hover span { border-color: var(--accent); }
80
+ .status-filter-option:has(input:checked) span { border-color: var(--accent); background: var(--accent); color: white; box-shadow: 0 1px 2px rgb(24 33 29 / 16%); }
81
+ .status-filter-option:has(input:focus-visible) span { outline: 3px solid var(--accent); outline-offset: 3px; }
73
82
  .task-count { margin: 0 0 9px auto; color: var(--muted); font-size: 0.9rem; }
74
83
 
75
84
  .task-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
@@ -94,6 +103,7 @@ time, .timestamp-missing { font-size: 0.78rem; }
94
103
  .status-completed { background: var(--accent-soft); color: var(--accent); }
95
104
  .status-failed { background: #f3dfdd; color: var(--danger); }
96
105
  .status-needs_input { background: #f4e9ce; color: var(--warning); }
106
+ .status-interrupted { background: #e8e5df; color: #625d54; }
97
107
 
98
108
  .empty-state { padding: 60px 20px; border: 1px dashed var(--border); border-radius: 10px; text-align: center; color: var(--muted); }
99
109
  .empty-state h2 { margin-bottom: 6px; color: var(--text); }
@@ -151,6 +161,7 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
151
161
  .title-row { align-items: flex-start; }
152
162
  .toolbar { align-items: stretch; flex-direction: column; }
153
163
  .toolbar label, .toolbar select { width: 100%; }
164
+ .toolbar .status-filter-option { width: auto; }
154
165
  .task-count { margin: 0; }
155
166
  .task-heading { align-items: flex-start; }
156
167
  .task-footer { align-items: flex-start; flex-direction: column; }
@@ -175,6 +186,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
175
186
  --warning: #e4bd77;
176
187
  }
177
188
  .dashboard-message { border-color: #66592e; background: #38321d; color: #f1d98c; }
189
+ .status-filter-option:has(input:checked) span { color: var(--background); }
178
190
  .status-failed { background: #4e2927; }
179
191
  .status-needs_input { background: #4c3b20; }
192
+ .status-interrupted { background: #37332f; color: #d6d0c7; }
180
193
  }
package/src/mcp.js CHANGED
@@ -21,7 +21,9 @@ 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), z.literal(7)]),
24
+ schemaVersion: z.union([
25
+ z.literal(4), z.literal(5), z.literal(6), z.literal(7), z.literal(8),
26
+ ]),
25
27
  id: z.string(),
26
28
  project: projectSchema,
27
29
  title: z.string(),
@@ -38,7 +40,7 @@ const taskSchema = z.object({
38
40
  requestSummary: z.string().nullable(),
39
41
  startedAt: z.string(),
40
42
  result: z.object({
41
- status: z.enum(["needs_input", "completed", "failed"]),
43
+ status: z.enum(["needs_input", "completed", "failed", "interrupted"]),
42
44
  summary: z.string(),
43
45
  updatedAt: z.string(),
44
46
  }).nullable(),
@@ -48,7 +50,7 @@ const taskSchema = z.object({
48
50
  requestSummary: z.string().nullable(),
49
51
  startedAt: z.string(),
50
52
  result: z.object({
51
- status: z.enum(["needs_input", "completed", "failed"]),
53
+ status: z.enum(["needs_input", "completed", "failed", "interrupted"]),
52
54
  summary: z.string(),
53
55
  updatedAt: z.string(),
54
56
  }).nullable(),
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 = 7;
42
- const PREVIOUS_TASK_SCHEMA_VERSION = 6;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 8;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 7;
43
+ const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
43
44
  const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
44
45
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
45
46
  const PROJECT_FIELDS = new Set([
@@ -75,6 +76,7 @@ const RECORD_DISPATCH_FIELDS = new Set([
75
76
  "threadId",
76
77
  ]);
77
78
  const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
79
+ const TURN_RESULT_STATUSES = new Set([...RESULT_STATUSES, "interrupted"]);
78
80
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
79
81
  const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
80
82
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
@@ -82,6 +84,7 @@ const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
82
84
  const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
83
85
  const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
84
86
  const TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
87
+ const INTERRUPTED_TURN_SUMMARY = "Turn interrupted before a terminal report.";
85
88
 
86
89
  function requireExactFields(value, fields, name) {
87
90
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -228,7 +231,7 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
228
231
  const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
229
232
  const content = await readFile(dispatchPath, "utf8");
230
233
  const appended = dispatches
231
- .map((dispatch) => `${JSON.stringify(schema7Task(dispatch))}\n`)
234
+ .map((dispatch) => `${JSON.stringify(currentSchemaTask(dispatch))}\n`)
232
235
  .join("");
233
236
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
234
237
  }
@@ -689,6 +692,7 @@ async function validateDispatchShape(dispatch, name = "task") {
689
692
  const supportedVersions = [
690
693
  FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
691
694
  5,
695
+ LEGACY_RESULTS_TASK_SCHEMA_VERSION,
692
696
  PREVIOUS_TASK_SCHEMA_VERSION,
693
697
  CURRENT_TASK_SCHEMA_VERSION,
694
698
  ];
@@ -697,9 +701,9 @@ async function validateDispatchShape(dispatch, name = "task") {
697
701
  }
698
702
  requireExactFields(
699
703
  dispatch,
700
- dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
704
+ dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION
701
705
  ? DISPATCH_FIELDS
702
- : dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
706
+ : dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION
703
707
  ? SCHEMA_6_DISPATCH_FIELDS
704
708
  : dispatch.schemaVersion === 5
705
709
  ? SCHEMA_5_DISPATCH_FIELDS
@@ -746,7 +750,13 @@ async function validateDispatchShape(dispatch, name = "task") {
746
750
  if (result === null) return null;
747
751
  requireExactFields(result, TURN_RESULT_FIELDS, resultName);
748
752
  const normalizedResult = {
749
- status: requireEnum(result.status, RESULT_STATUSES, `${resultName}.status`),
753
+ status: requireEnum(
754
+ result.status,
755
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
756
+ ? TURN_RESULT_STATUSES
757
+ : RESULT_STATUSES,
758
+ `${resultName}.status`,
759
+ ),
750
760
  summary: optionalString(result.summary, `${resultName}.summary`, {
751
761
  maxLength: MAX_RESULT_SUMMARY_LENGTH,
752
762
  }),
@@ -755,6 +765,12 @@ async function validateDispatchShape(dispatch, name = "task") {
755
765
  if (normalizedResult.summary === null) {
756
766
  throw new Error(`${resultName}.summary must be a non-empty string`);
757
767
  }
768
+ if (
769
+ normalizedResult.status === "interrupted"
770
+ && normalizedResult.summary !== INTERRUPTED_TURN_SUMMARY
771
+ ) {
772
+ throw new Error(`${resultName}.summary must use the TaskChef interrupted-turn summary`);
773
+ }
758
774
  return normalizedResult;
759
775
  };
760
776
  const normalizeTurn = (turn, turnName) => {
@@ -770,10 +786,10 @@ async function validateDispatchShape(dispatch, name = "task") {
770
786
  };
771
787
  let legacyResults = [];
772
788
  let turns = [];
773
- if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
789
+ if (dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION) {
774
790
  if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
775
791
  turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
776
- } else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
792
+ } else if (dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION) {
777
793
  if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
778
794
  legacyResults = dispatch.results.map((result, index) => (
779
795
  normalizeResult(result, `${name}.results[${index}]`)
@@ -785,7 +801,7 @@ async function validateDispatchShape(dispatch, name = "task") {
785
801
  } else if (RESULT_STATUSES.has(status)) {
786
802
  legacyResults = [{ status, summary, turnId, updatedAt }];
787
803
  }
788
- if (dispatch.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
804
+ if (dispatch.schemaVersion < PREVIOUS_TASK_SCHEMA_VERSION) {
789
805
  turns = legacyResults.map((result) => ({
790
806
  turnId: result.turnId,
791
807
  requestSummary: null,
@@ -807,7 +823,9 @@ async function validateDispatchShape(dispatch, name = "task") {
807
823
  turns.push({ turnId, requestSummary: null, startedAt: updatedAt, result: null });
808
824
  }
809
825
  }
810
- const results = turns.flatMap((turn) => turn.result === null ? [] : [{
826
+ const results = turns.flatMap((turn) => (
827
+ turn.result === null || !RESULT_STATUSES.has(turn.result.status)
828
+ ) ? [] : [{
811
829
  ...turn.result,
812
830
  turnId: turn.turnId,
813
831
  }]);
@@ -847,7 +865,9 @@ async function validateDispatchShape(dispatch, name = "task") {
847
865
  );
848
866
  }
849
867
  }
850
- normalized.results = normalized.turns.flatMap((turn) => turn.result === null ? [] : [{
868
+ normalized.results = normalized.turns.flatMap((turn) => (
869
+ turn.result === null || !RESULT_STATUSES.has(turn.result.status)
870
+ ) ? [] : [{
851
871
  ...turn.result,
852
872
  turnId: turn.turnId,
853
873
  }]);
@@ -1076,7 +1096,7 @@ export async function parseTaskLogContent(workspaceRoot, content) {
1076
1096
  return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
1077
1097
  }
1078
1098
 
1079
- function schema7Task(dispatch, patch = {}) {
1099
+ function currentSchemaTask(dispatch, patch = {}) {
1080
1100
  const {
1081
1101
  latestTurn: _latestTurn,
1082
1102
  results: _results,
@@ -1115,13 +1135,13 @@ export async function migrateTaskLog(workspaceRoot, {
1115
1135
  backupPath: null,
1116
1136
  };
1117
1137
  }
1118
- const lines = records.map((record) => JSON.stringify(schema7Task(record.normalized)));
1138
+ const lines = records.map((record) => JSON.stringify(currentSchemaTask(record.normalized)));
1119
1139
  const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
1120
1140
  await parseDispatchRecordsUnlocked(root, migrated);
1121
1141
  const timestamp = requireTimestamp(now(), "migration timestamp")
1122
1142
  .replaceAll(":", "-")
1123
1143
  .replaceAll(".", "-");
1124
- const backupPath = `${dispatchPath}.pre-v7-${timestamp}-${randomUUID()}.bak`;
1144
+ const backupPath = `${dispatchPath}.pre-v8-${timestamp}-${randomUUID()}.bak`;
1125
1145
  await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1126
1146
  if (await readFile(backupPath, "utf8") !== original) {
1127
1147
  throw new Error(`task log backup validation failed: ${backupPath}`);
@@ -1209,7 +1229,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1209
1229
  throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
1210
1230
  }
1211
1231
  if (dispatch.threadId === durableThreadId) return dispatch;
1212
- const canonical = await validateDispatchShape(schema7Task(dispatch, {
1232
+ const canonical = await validateDispatchShape(currentSchemaTask(dispatch, {
1213
1233
  threadId: durableThreadId,
1214
1234
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1215
1235
  updatedBy: "mcp",
@@ -1238,7 +1258,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1238
1258
  ))) {
1239
1259
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
1240
1260
  }
1241
- const linked = await validateDispatchShape(schema7Task(dispatch, {
1261
+ const linked = await validateDispatchShape(currentSchemaTask(dispatch, {
1242
1262
  threadId: durableThreadId,
1243
1263
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1244
1264
  updatedBy: "mcp",
@@ -1252,7 +1272,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1252
1272
  }
1253
1273
 
1254
1274
  function dispatchLineWithState(dispatch, patch) {
1255
- return JSON.stringify(schema7Task(dispatch, patch));
1275
+ return JSON.stringify(currentSchemaTask(dispatch, patch));
1256
1276
  }
1257
1277
 
1258
1278
  function normalizeTaskStateInput(input, { allowWorking }) {
@@ -1374,6 +1394,21 @@ async function reportTaskStateInternal(
1374
1394
  }
1375
1395
  if (status === "working") {
1376
1396
  const sameWorkingTurn = dispatch.status === "working" && stateTurnId === dispatch.turnId;
1397
+ const recordedTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
1398
+ if (!sameWorkingTurn && recordedTurn) {
1399
+ if (
1400
+ requestSummary !== null
1401
+ && recordedTurn.requestSummary !== requestSummary
1402
+ ) {
1403
+ throw new Error(`working turn already has a different requestSummary: ${id}`);
1404
+ }
1405
+ if (
1406
+ recordedTurn.requestSummary !== null
1407
+ || recordedTurn.result?.status === "interrupted"
1408
+ ) {
1409
+ return dispatch;
1410
+ }
1411
+ }
1377
1412
  if (sameWorkingTurn) {
1378
1413
  const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
1379
1414
  if (
@@ -1401,17 +1436,33 @@ async function reportTaskStateInternal(
1401
1436
  const updatedAt = sameWorkingTurn
1402
1437
  ? dispatch.updatedAt
1403
1438
  : transitionTimestamp(now, dispatch.updatedAt);
1439
+ const recoveredTurns = (
1440
+ !sameWorkingTurn
1441
+ && dispatch.status === "working"
1442
+ && dispatch.latestTurn?.result === null
1443
+ )
1444
+ ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
1445
+ ? {
1446
+ ...turn,
1447
+ result: {
1448
+ status: "interrupted",
1449
+ summary: INTERRUPTED_TURN_SUMMARY,
1450
+ updatedAt,
1451
+ },
1452
+ }
1453
+ : turn)
1454
+ : dispatch.turns;
1404
1455
  const turns = sameWorkingTurn
1405
1456
  ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
1406
1457
  ? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
1407
1458
  : turn)
1408
- : [...dispatch.turns, {
1459
+ : [...recoveredTurns, {
1409
1460
  turnId: stateTurnId,
1410
1461
  requestSummary,
1411
1462
  startedAt: updatedAt,
1412
1463
  result: null,
1413
1464
  }];
1414
- const updated = await validateDispatchShape(schema7Task(dispatch, {
1465
+ const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
1415
1466
  status,
1416
1467
  summary: null,
1417
1468
  turnId: stateTurnId,
@@ -1465,7 +1516,7 @@ async function reportTaskStateInternal(
1465
1516
  ? { ...turn, result: turnResult }
1466
1517
  : turn);
1467
1518
  }
1468
- const candidate = schema7Task(dispatch, {
1519
+ const candidate = currentSchemaTask(dispatch, {
1469
1520
  status,
1470
1521
  summary,
1471
1522
  turnId: stateTurnId,