taskchef 7.9.0 → 7.10.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.0",
3
+ "version": "7.10.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:
@@ -104,7 +104,6 @@ For example, TaskChef generates this shape:
104
104
 
105
105
  ```text
106
106
  Fix duplicate charges after a retry and add a regression test.
107
-
108
107
  <!-- taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7 -->
109
108
  Use $taskchef-executor to execute and report this delegated TaskChef assignment.
110
109
  ```
@@ -124,7 +123,12 @@ appends a `turns` entry that pairs that request with a null result while working
124
123
  then fills the same entry with the semantic outcome. A follow-up therefore shows
125
124
  its own request with “In progress,” never the preceding turn's result. Returned
126
125
  tasks still derive `results` and `lastResult` as compatibility projections.
127
- 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.
128
132
 
129
133
  Delegated tasks created by earlier TaskChef versions remain compatible: their
130
134
  inline executor protocol still parses, self-links, and may use the deprecated
@@ -176,7 +180,7 @@ taskchef dashboard --port 3211
176
180
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
177
181
  linked Codex tasks. List snapshots and SSE events carry only the latest
178
182
  request/result pair; opening task details fetches the full newest-first activity
179
- timeline.
183
+ timeline, including clearly labeled interrupted turns.
180
184
  The header shows the running TaskChef package version reported by the same
181
185
  bounded health identity used for compatible-listener checks.
182
186
  Task and result times are relative through 29 days (with minute detail for the
@@ -206,6 +210,8 @@ no task data, credentials, environment variables, process control, or secrets.
206
210
 
207
211
  ![Task detail activity timeline](docs/images/result-history-dashboard.jpg)
208
212
 
213
+ ![Interrupted turn followed by active recovery](docs/images/interrupted-turn-recovery.jpg)
214
+
209
215
  ## Common recovery
210
216
 
211
217
  Check the managed workspace:
@@ -219,8 +225,8 @@ taskchef doctor
219
225
 
220
226
  `doctor` is read-only. `workspace init` creates missing files and refreshes
221
227
  managed instructions. `workspace migrate` explicitly upgrades supported schema
222
- 4/5/6 task lines to schema 7 under the workspace lock. It validates the complete
223
- 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`
224
230
  backup, atomically replaces the log, validates the result, and becomes an
225
231
  idempotent no-op after migration. If replacement fails, the original remains
226
232
  or the reported backup can be restored; unsupported or invalid input is rejected
@@ -231,7 +237,7 @@ executor so its first action can retry `link_task`. Do not guess an identity
231
237
  or edit `tasks.jsonl`. If native task creation failed, the record is retained
232
238
  as `failed` with null thread and turn IDs.
233
239
 
234
- 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
235
241
  unchanged and create a current workspace; the migration command deliberately
236
242
  does not guess how to convert unknown formats.
237
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.
@@ -116,10 +119,10 @@ the final link, preserving the delegate skill's immediate-return contract.
116
119
  1. The dispatcher MUST call `prepare_dispatch` once per outcome.
117
120
  2. It MUST choose exactly one configured project and exact native-project path.
118
121
  3. It MUST build the instruction with the user's outcome beginning on line 1
119
- and remaining uninterrupted, followed by one blank line, the returned
120
- marker, and exactly one concise explicit `$taskchef-executor` invocation on
121
- the final line. It MUST NOT inline the executor protocol into a new
122
- instruction.
122
+ and remaining uninterrupted, followed by exactly one newline, the returned
123
+ marker, one newline, and exactly one concise explicit `$taskchef-executor`
124
+ invocation on the final line. It MUST NOT place a blank line before or after
125
+ the marker or inline the executor protocol into a new instruction.
123
126
  4. It MUST call `record_task` with `threadId: null` before native creation.
124
127
  5. It MUST create exactly one native Codex executor and return immediately.
125
128
  6. The executor MUST read its own `CODEX_THREAD_ID` and call `link_task`
@@ -129,16 +132,19 @@ 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.
136
141
  A link failure MUST remain visible and retryable; the executor MUST report it
137
142
  visibly and MUST NOT continue substantive work.
138
143
 
139
- Previously recorded instructions with a first-line HTML marker, the older
140
- first-line `# taskchef_id=<full UUID>` heading, or the former blank line and
141
- inline executor protocol MUST remain marker-readable and executable. Their
144
+ Previously recorded instructions with the former blank line before a trailing
145
+ marker, a first-line HTML marker, the older first-line
146
+ `# taskchef_id=<full UUID>` heading, or the former blank line and inline
147
+ executor protocol MUST remain marker-readable and executable. Their
142
148
  `report_result` calls MUST remain supported by the deprecated alias. New
143
149
  instructions MUST use the trailing marker and explicit executor skill contract
144
150
  above. A historical first-line instruction with an executor-skill invocation
@@ -237,7 +243,7 @@ new preparation values, though it writes no state.
237
243
 
238
244
  **Structured output:** `{ task: Task }`.
239
245
 
240
- 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,
241
247
  empty `turns` and derived `results` arrays,
242
248
  `updatedBy: dispatcher`, and equal creation/update timestamps. Duplicate IDs,
243
249
  unknown projects, malformed markers, and invalid input fail. Repeating a
@@ -282,16 +288,23 @@ marker, or ineligible state fails.
282
288
 
283
289
  **Structured output:** `{ task: Task }`.
284
290
 
285
- For a linked self-linking journey, `working` MUST identify a turn newer than
286
- the current turn and last semantic result. A semantic state MUST match the
287
- current working turn. Repeating an identical state is idempotent; conflicting
288
- 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
289
296
  creation `failed` state with both IDs null. Starting work appends one turn with
290
- 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.
291
300
  An identical retry for any settled turn returns success without an append. A
292
301
  different request or result for the same turn, a stale turn, or a semantic
293
302
  result that does not match the active working turn MUST fail.
294
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
+
295
308
  **Annotations:** `readOnlyHint: false`, `destructiveHint: true`,
296
309
  `openWorldHint: false`.
297
310
 
@@ -301,8 +314,8 @@ result that does not match the active working turn MUST fail.
301
314
  temporary compatibility alias. It implicitly accepts a fresh supplied turn and
302
315
  stores its semantic result in a request-unknown turn, including for supported schema-4/5/6 records and
303
316
  low-level opaque direct records. It does not accept `working`. New executor
304
- instructions MUST use `report_state`. Successful mutation upgrades schema 4/5/6
305
- 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.
306
319
 
307
320
  ## Reporting and dashboard
308
321
 
@@ -358,12 +371,13 @@ its displayed summary when present, event time, and missing-task state.
358
371
 
359
372
  ## Task-log migration
360
373
 
361
- `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
362
375
  under the shared lock. Each legacy semantic result becomes a request-unknown
363
- completed turn; a newer working state becomes a final unfinished turn. Migration
364
- 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,
365
379
  create and read back an exclusive recovery backup, atomically replace the log,
366
- 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
367
381
  no-op without another backup. Invalid/unsupported input MUST remain untouched;
368
382
  failures after backup creation MUST report the backup path and MUST never
369
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.0",
3
+ "version": "7.10.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",
@@ -52,8 +52,9 @@ stop and report that the TaskChef plugin must be reloaded or installed.
52
52
 
53
53
  - Begin with the actual assignment on the first line and keep its complete
54
54
  body uninterrupted.
55
- - After the assignment, add one blank line and the preparation's exact
56
- marker on its own line.
55
+ - After the assignment's final character, add exactly one newline and the
56
+ preparation's exact marker on its own line. Do not add a blank line before
57
+ or after the marker.
57
58
  - Immediately after the marker, end the instruction with exactly:
58
59
  `Use $taskchef-executor to execute and report this delegated TaskChef assignment.`
59
60
  - Include exactly one marker and exactly one executor-skill invocation.
@@ -9,12 +9,13 @@ Own and execute the delegated assignment in the current Codex task. Do not
9
9
  re-dispatch it merely because it concerns TaskChef or a configured project.
10
10
  Explicit requests to delegate separate work remain valid.
11
11
 
12
- New instructions present the complete assignment first, followed by one blank
13
- line, the exact `<!-- taskchef_id=<full UUID> -->` marker, and the final
14
- explicit skill invocation. Treat that UUID as the TaskChef task ID. The
15
- assignment is everything before the blank line that precedes the marker; the
16
- marker and invocation are lifecycle scaffolding, not part of the deliverable.
17
- Require exactly one marker and do not infer an ID from similar prose.
12
+ New instructions present the complete assignment first, followed by exactly one
13
+ newline, the exact `<!-- taskchef_id=<full UUID> -->` marker, one newline, and
14
+ the final explicit skill invocation. There is no blank line before or after the
15
+ marker. Treat that UUID as the TaskChef task ID. The assignment is everything
16
+ before the marker; the marker and invocation are lifecycle scaffolding, not
17
+ part of the deliverable. Require exactly one marker and do not infer an ID from
18
+ similar prose.
18
19
 
19
20
  ## Start every execution turn
20
21
 
@@ -34,6 +35,12 @@ Complete this lifecycle setup before substantive assignment work:
34
35
  current turn ID, `status: working`, an omitted or null result summary, and a
35
36
  concise `requestSummary` describing this turn's assignment or follow-up.
36
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
+
37
44
  If `CODEX_THREAD_ID`, exact native thread reading, or a required TaskChef tool
38
45
  is unavailable, or if linking or the working-state report fails, report the
39
46
  failure visibly and stop before substantive work. Retry on a later turn. Never
@@ -52,10 +59,11 @@ the current turn with one semantic status and a concise summary:
52
59
  A live native approval prompt is Codex state, not semantic `needs_input`; leave
53
60
  the approval live instead of storing it as a TaskChef result. Never invent or
54
61
  reuse a turn ID after a follow-up. If a final-report response is lost, an
55
- identical retry is safe only while the same turn remains current. On a later
56
- turn, run the start lifecycle with its new current turn ID and report that
57
- turn's actual outcome. Say reporting failures visibly instead of claiming a
58
- 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.
59
67
 
60
68
  Request and result summaries must omit secrets, transcripts, raw command output, hidden reasoning,
61
69
  and unnecessary personal data. Identical lifecycle retries are safe; never
@@ -69,12 +77,13 @@ Existing delegated tasks may include the former inline ownership, linking, and
69
77
  re-dispatching. Prefer `report_state` when available. If an older installed
70
78
  TaskChef exposes only `report_result`, follow its inline protocol; after an
71
79
  upgrade, the deprecated `report_result` alias remains available for exact
72
- legacy retries. Also accept historical instructions whose exact HTML marker is
73
- the first line, with or without the former blank line, and the older exact
74
- first-line `# taskchef_id=<full UUID>` heading. These compatibility forms do
75
- not change the identity or lifecycle rules above. For either first-line form,
76
- the assignment follows the marker. Ignore the final executor invocation and
77
- any recognizable former inline ownership, linking, working-state, or
80
+ legacy retries. Also accept historical instructions with the former blank line
81
+ before a trailing marker, an exact HTML marker on the first line with or
82
+ without the former blank line, or the older exact
83
+ first-line `# taskchef_id=<full UUID>` heading. These compatibility forms do not
84
+ change the identity or lifecycle rules above. For either first-line form, the
85
+ assignment follows the marker. Ignore the final executor invocation and any
86
+ recognizable former inline ownership, linking, working-state, or
78
87
  result-reporting paragraphs as lifecycle scaffolding; execute the remaining
79
88
  task-specific body. Require non-whitespace task-specific content and never
80
89
  treat an invocation by itself as an assignment.
@@ -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.
@@ -12,6 +12,7 @@ import {
12
12
  reconcileNotifications,
13
13
  taskStatusLabel,
14
14
  taskWithinDateFilter,
15
+ turnPresentation,
15
16
  } from "./state.js";
16
17
  import { openTaskFromControl } from "./actions.js";
17
18
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
@@ -237,11 +238,12 @@ function turnTimeline(task) {
237
238
  const header = document.createElement("div");
238
239
  header.className = "result-history-header";
239
240
  const status = document.createElement("span");
240
- const turnStatus = turn.result?.status ?? "working";
241
+ const presentation = turnPresentation(turn);
242
+ const turnStatus = presentation.status;
241
243
  status.className = `status status-${turnStatus}`;
242
244
  status.textContent = turnStatus.replaceAll("_", " ");
243
245
  const turnKey = turn.turnId ?? `no-turn:${index}`;
244
- const timestamp = timestampControl(turn.result?.updatedAt ?? turn.startedAt, {
246
+ const timestamp = timestampControl(presentation.updatedAt, {
245
247
  accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
246
248
  key: `detail:${task.id}:turn:${turnKey}`,
247
249
  });
@@ -255,7 +257,7 @@ function turnTimeline(task) {
255
257
  resultLabel.textContent = "Result";
256
258
  const result = document.createElement("p");
257
259
  result.className = "preserve-lines";
258
- result.textContent = turn.result?.summary ?? "In progress";
260
+ result.textContent = presentation.summary;
259
261
  const turnMetadata = document.createElement("p");
260
262
  turnMetadata.className = "result-history-turn";
261
263
  turnMetadata.textContent = turn.turnId
@@ -1,4 +1,5 @@
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",
@@ -49,6 +50,15 @@ export function latestTurnPresentation(task) {
49
50
  };
50
51
  }
51
52
 
53
+ export function turnPresentation(turn) {
54
+ const result = turn.result ?? null;
55
+ return {
56
+ status: result?.status ?? "working",
57
+ summary: result?.summary ?? "In progress",
58
+ updatedAt: result?.updatedAt ?? turn.startedAt,
59
+ };
60
+ }
61
+
52
62
  export function mergeProjectedTurns(task, preservedTurns = []) {
53
63
  if (Array.isArray(task.turns)) return task.turns;
54
64
  if (!task.latestTurn) return preservedTurns;
@@ -57,6 +67,20 @@ export function mergeProjectedTurns(task, preservedTurns = []) {
57
67
  if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
58
68
  turns[lastIndex] = task.latestTurn;
59
69
  } else {
70
+ if (
71
+ task.schemaVersion === 8
72
+ && lastIndex >= 0
73
+ && turns[lastIndex].result === null
74
+ ) {
75
+ turns[lastIndex] = {
76
+ ...turns[lastIndex],
77
+ result: {
78
+ status: "interrupted",
79
+ summary: INTERRUPTED_TURN_SUMMARY,
80
+ updatedAt: task.latestTurn.startedAt,
81
+ },
82
+ };
83
+ }
60
84
  turns.push(task.latestTurn);
61
85
  }
62
86
  return turns;
@@ -94,6 +94,7 @@ time, .timestamp-missing { font-size: 0.78rem; }
94
94
  .status-completed { background: var(--accent-soft); color: var(--accent); }
95
95
  .status-failed { background: #f3dfdd; color: var(--danger); }
96
96
  .status-needs_input { background: #f4e9ce; color: var(--warning); }
97
+ .status-interrupted { background: #e8e5df; color: #625d54; }
97
98
 
98
99
  .empty-state { padding: 60px 20px; border: 1px dashed var(--border); border-radius: 10px; text-align: center; color: var(--muted); }
99
100
  .empty-state h2 { margin-bottom: 6px; color: var(--text); }
@@ -177,4 +178,5 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
177
178
  .dashboard-message { border-color: #66592e; background: #38321d; color: #f1d98c; }
178
179
  .status-failed { background: #4e2927; }
179
180
  .status-needs_input { background: #4c3b20; }
181
+ .status-interrupted { background: #37332f; color: #d6d0c7; }
180
182
  }
package/src/delegation.js CHANGED
@@ -162,14 +162,18 @@ export function parseTaskChefMarker(instruction) {
162
162
  return hasHistoricalAssignment() ? id : null;
163
163
  }
164
164
  const executorSkillReferences = instruction.match(/\$taskchef-executor\b/gi) ?? [];
165
+ const hasCompactBoundary = index >= 1
166
+ && lines.at(index - 1).trim().length > 0;
167
+ const hasHistoricalBlankBoundary = index >= 2
168
+ && lines.at(index - 1) === ""
169
+ && lines.at(index - 2).trim().length > 0;
170
+ const assignmentEnd = hasCompactBoundary ? index : index - 1;
165
171
  const isTrailingScaffold = index === lines.length - 2
166
- && index >= 2
172
+ && (hasCompactBoundary || hasHistoricalBlankBoundary)
167
173
  && lines[0].trim().length > 0
168
- && lines.at(-3) === ""
169
- && lines.at(-4).trim().length > 0
170
174
  && lines.at(-1) === EXECUTOR_SKILL_INVOCATION
171
- && hasTaskSpecificContent(lines.slice(0, index - 1))
172
- && !lines.slice(0, index - 1).some((line) => HISTORICAL_EXECUTOR_SCAFFOLD_LINES.has(line))
175
+ && hasTaskSpecificContent(lines.slice(0, assignmentEnd))
176
+ && !lines.slice(0, assignmentEnd).some((line) => HISTORICAL_EXECUTOR_SCAFFOLD_LINES.has(line))
173
177
  && executorSkillReferences.length === 1;
174
178
  return isTrailingScaffold ? id : null;
175
179
  }
@@ -198,7 +202,7 @@ export function prepareDelegation(instruction, { taskId = randomUUID() } = {}) {
198
202
  const id = requireUuid(taskId);
199
203
  return {
200
204
  id,
201
- instruction: `${body}\n\n${taskChefMarker(id)}\n${EXECUTOR_SKILL_INVOCATION}`,
205
+ instruction: `${body}\n${taskChefMarker(id)}\n${EXECUTOR_SKILL_INVOCATION}`,
202
206
  };
203
207
  }
204
208
 
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,