taskchef 7.8.0 → 7.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +17 -16
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +28 -28
- package/docs/workflows.md +23 -22
- package/package.json +1 -1
- package/skills/taskchef-executor/SKILL.md +3 -2
- package/skills/taskchef-report/SKILL.md +8 -9
- package/src/cli.js +4 -4
- package/src/dashboard/app.js +44 -20
- package/src/dashboard/index.html +1 -1
- package/src/dashboard/state.js +31 -0
- package/src/dashboard/styles.css +3 -1
- package/src/dashboard.js +12 -1
- package/src/delegation.js +1 -1
- package/src/mcp.js +23 -2
- package/src/workspace.js +227 -50
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
TaskChef is a local dispatch desk for Codex. Give one dispatcher a request and
|
|
4
4
|
it records each independently useful outcome, creates a normal Codex task in
|
|
5
5
|
the right project, and returns immediately. The executor task is where live
|
|
6
|
-
work, approvals, and follow-ups happen; TaskChef
|
|
7
|
-
while projecting the latest
|
|
6
|
+
work, approvals, and follow-ups happen; TaskChef pairs each turn's request with
|
|
7
|
+
its semantic result while projecting the latest pair for compact navigation.
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
request -> recorded TaskChef task -> Codex executor ->
|
|
10
|
+
request -> recorded TaskChef task -> Codex executor -> request/result turn timeline
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Which document should I read?
|
|
@@ -49,7 +49,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
|
|
|
49
49
|
```text
|
|
50
50
|
AGENTS.md managed dispatcher instructions plus user additions
|
|
51
51
|
taskchef.json schema-2 configured projects and routing metadata
|
|
52
|
-
tasks.jsonl one task snapshot per line (schema
|
|
52
|
+
tasks.jsonl one task snapshot per line (schema 7; schema 4/5/6 migration supported)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
List or change routing targets conversationally:
|
|
@@ -119,12 +119,12 @@ when a turn starts and one semantic outcome before that same turn ends:
|
|
|
119
119
|
- `failed`: the executor or creation attempt ended unsuccessfully.
|
|
120
120
|
|
|
121
121
|
A native approval prompt is live Codex state, not `needs_input`.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
When work starts, the executor reports a concise request summary. TaskChef
|
|
123
|
+
appends a `turns` entry that pairs that request with a null result while working,
|
|
124
|
+
then fills the same entry with the semantic outcome. A follow-up therefore shows
|
|
125
|
+
its own request with “In progress,” never the preceding turn's result. Returned
|
|
126
|
+
tasks still derive `results` and `lastResult` as compatibility projections.
|
|
127
|
+
TaskChef does not store transcripts, hidden reasoning, or non-semantic events.
|
|
128
128
|
|
|
129
129
|
Delegated tasks created by earlier TaskChef versions remain compatible: their
|
|
130
130
|
inline executor protocol still parses, self-links, and may use the deprecated
|
|
@@ -174,8 +174,9 @@ taskchef dashboard --port 3211
|
|
|
174
174
|
```
|
|
175
175
|
|
|
176
176
|
The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
|
|
177
|
-
linked Codex tasks. List snapshots and SSE events carry only the latest
|
|
178
|
-
|
|
177
|
+
linked Codex tasks. List snapshots and SSE events carry only the latest
|
|
178
|
+
request/result pair; opening task details fetches the full newest-first activity
|
|
179
|
+
timeline.
|
|
179
180
|
The header shows the running TaskChef package version reported by the same
|
|
180
181
|
bounded health identity used for compatible-listener checks.
|
|
181
182
|
Task and result times are relative through 29 days (with minute detail for the
|
|
@@ -203,7 +204,7 @@ no task data, credentials, environment variables, process control, or secrets.
|
|
|
203
204
|
|
|
204
205
|

|
|
205
206
|
|
|
206
|
-

|
|
207
208
|
|
|
208
209
|
## Common recovery
|
|
209
210
|
|
|
@@ -218,8 +219,8 @@ taskchef doctor
|
|
|
218
219
|
|
|
219
220
|
`doctor` is read-only. `workspace init` creates missing files and refreshes
|
|
220
221
|
managed instructions. `workspace migrate` explicitly upgrades supported schema
|
|
221
|
-
4/5 task lines to schema
|
|
222
|
-
source and converted log before writing, creates an exclusive `tasks.jsonl.pre-
|
|
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`
|
|
223
224
|
backup, atomically replaces the log, validates the result, and becomes an
|
|
224
225
|
idempotent no-op after migration. If replacement fails, the original remains
|
|
225
226
|
or the reported backup can be restored; unsupported or invalid input is rejected
|
|
@@ -230,7 +231,7 @@ executor so its first action can retry `link_task`. Do not guess an identity
|
|
|
230
231
|
or edit `tasks.jsonl`. If native task creation failed, the record is retained
|
|
231
232
|
as `failed` with null thread and turn IDs.
|
|
232
233
|
|
|
233
|
-
Schemas other than 4, 5, and
|
|
234
|
+
Schemas other than 4, 5, 6, and 7 remain unsupported. Retain such a workspace
|
|
234
235
|
unchanged and create a current workspace; the migration command deliberately
|
|
235
236
|
does not guess how to convert unknown formats.
|
|
236
237
|
|
|
Binary file
|
package/docs/spec.md
CHANGED
|
@@ -23,7 +23,7 @@ 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
|
-
| **
|
|
26
|
+
| **Turn timeline** | The ordered collection pairing each turn's concise request summary with its eventual semantic result. |
|
|
27
27
|
| **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
|
|
28
28
|
| **Current turn ID** | The canonical Codex UUIDv7 returned by an exact native read of the linked executor for the turn being reported. |
|
|
29
29
|
| **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
|
|
@@ -69,8 +69,8 @@ Repository URLs MUST canonicalize to `https://github.com/<owner>/<repository>`
|
|
|
69
69
|
and be case-insensitively deduplicated.
|
|
70
70
|
|
|
71
71
|
`tasks.jsonl` MUST contain zero or more newline-terminated schema-4, schema-5,
|
|
72
|
-
or schema-
|
|
73
|
-
formats; every new record and state mutation MUST write schema
|
|
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
|
|
74
74
|
or unsupported fields MUST be rejected without conversion.
|
|
75
75
|
Reads and writes MUST reject symlinked managed files. Mutations
|
|
76
76
|
MUST hold the shared workspace lock and replace state atomically; read-only
|
|
@@ -82,7 +82,7 @@ Every record MUST contain exactly these fields:
|
|
|
82
82
|
|
|
83
83
|
| Field | Contract |
|
|
84
84
|
| --- | --- |
|
|
85
|
-
| `schemaVersion` | Integer `
|
|
85
|
+
| `schemaVersion` | Integer `7`; schema-4/5/6 records remain readable until explicit migration or their next mutation. |
|
|
86
86
|
| `id` | Unique safe TaskChef ID; delegation uses a lowercase full UUID. |
|
|
87
87
|
| `project` | Immutable configured-project snapshot. |
|
|
88
88
|
| `title` | Non-empty display title. |
|
|
@@ -94,13 +94,12 @@ Every record MUST contain exactly these fields:
|
|
|
94
94
|
| `turnId` | Null before turn reporting; otherwise the current reported turn. Linked MCP journeys use a canonical Codex UUIDv7. |
|
|
95
95
|
| `updatedAt` | ISO 8601 timestamp not earlier than `createdAt` or the prior `updatedAt`; clock rollback cannot backdate a transition. |
|
|
96
96
|
| `updatedBy` | `dispatcher` or `mcp`. |
|
|
97
|
-
| `
|
|
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
98
|
|
|
99
|
-
Returned Task objects MUST additionally expose `
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
next major version after callers migrate to `results.at(-1)`.
|
|
99
|
+
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.
|
|
104
103
|
|
|
105
104
|
Task IDs and non-null thread identities MUST be unique. The immutable intent
|
|
106
105
|
fields MUST NOT change after recording.
|
|
@@ -238,8 +237,8 @@ new preparation values, though it writes no state.
|
|
|
238
237
|
|
|
239
238
|
**Structured output:** `{ task: Task }`.
|
|
240
239
|
|
|
241
|
-
The returned task has schema
|
|
242
|
-
|
|
240
|
+
The returned task has schema 7, `working`, null summary/turn/thread/latestTurn/lastResult,
|
|
241
|
+
empty `turns` and derived `results` arrays,
|
|
243
242
|
`updatedBy: dispatcher`, and equal creation/update timestamps. Duplicate IDs,
|
|
244
243
|
unknown projects, malformed markers, and invalid input fail. Repeating a
|
|
245
244
|
successful call is not idempotent; it fails as a duplicate.
|
|
@@ -268,7 +267,7 @@ marker, or ineligible state fails.
|
|
|
268
267
|
### `report_state`
|
|
269
268
|
|
|
270
269
|
**Caller:** executor, or dispatcher only for native creation failure.
|
|
271
|
-
**Mutation:** replaces the current state atomically and preserves `
|
|
270
|
+
**Mutation:** replaces the current state atomically and preserves `turns`.
|
|
272
271
|
|
|
273
272
|
**Input:**
|
|
274
273
|
|
|
@@ -279,6 +278,7 @@ marker, or ineligible state fails.
|
|
|
279
278
|
| `turnId` | Current canonical Codex UUIDv7 for a linked MCP journey; null only for creation failure. Maximum 256 characters at the MCP boundary. |
|
|
280
279
|
| `status` | `working`, `needs_input`, `completed`, or `failed`. |
|
|
281
280
|
| `summary` | Omitted or null for `working`; required non-empty string of at most 2,000 characters otherwise. |
|
|
281
|
+
| `requestSummary` | Concise current request of at most 1,000 characters for `working`; optional for backward compatibility and omitted for semantic states. |
|
|
282
282
|
|
|
283
283
|
**Structured output:** `{ task: Task }`.
|
|
284
284
|
|
|
@@ -286,10 +286,10 @@ For a linked self-linking journey, `working` MUST identify a turn newer than
|
|
|
286
286
|
the current turn and last semantic result. A semantic state MUST match the
|
|
287
287
|
current working turn. Repeating an identical state is idempotent; conflicting
|
|
288
288
|
or older state fails. A null-identity record accepts only a fresh executor
|
|
289
|
-
creation `failed` state with both IDs null.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
289
|
+
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.
|
|
291
|
+
An identical retry for any settled turn returns success without an append. A
|
|
292
|
+
different request or result for the same turn, a stale turn, or a semantic
|
|
293
293
|
result that does not match the active working turn MUST fail.
|
|
294
294
|
|
|
295
295
|
**Annotations:** `readOnlyHint: false`, `destructiveHint: true`,
|
|
@@ -299,10 +299,10 @@ result that does not match the active working turn MUST fail.
|
|
|
299
299
|
|
|
300
300
|
`report_result` retains the prior semantic-only input shape and statuses as a
|
|
301
301
|
temporary compatibility alias. It implicitly accepts a fresh supplied turn and
|
|
302
|
-
stores its semantic result, including for supported schema-4/5 records and
|
|
302
|
+
stores its semantic result in a request-unknown turn, including for supported schema-4/5/6 records and
|
|
303
303
|
low-level opaque direct records. It does not accept `working`. New executor
|
|
304
|
-
instructions MUST use `report_state`. Successful mutation upgrades schema 4/5
|
|
305
|
-
to schema
|
|
304
|
+
instructions MUST use `report_state`. Successful mutation upgrades schema 4/5/6
|
|
305
|
+
to schema 7; unsupported schemas remain rejected.
|
|
306
306
|
|
|
307
307
|
## Reporting and dashboard
|
|
308
308
|
|
|
@@ -324,9 +324,9 @@ startup safely. Direct thread navigation
|
|
|
324
324
|
MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
|
|
325
325
|
configured project. Project paths from task history MUST be matched against
|
|
326
326
|
current configuration before use.
|
|
327
|
-
Snapshot and SSE list payloads MUST omit full `
|
|
328
|
-
|
|
329
|
-
return the full validated task so the dialog can render
|
|
327
|
+
Snapshot and SSE list payloads MUST omit full `turns` and derived `results`
|
|
328
|
+
history and include `latestTurn`. The bounded per-task detail endpoint MAY
|
|
329
|
+
return the full validated task so the dialog can render the paired timeline newest first.
|
|
330
330
|
Dashboard notifications MUST capture an immutable event-time projection of the
|
|
331
331
|
task title, lifecycle state and event, turn ID when present, event timestamp,
|
|
332
332
|
and relevant concise summary. Rendering MUST NOT resolve historical notice text
|
|
@@ -358,12 +358,12 @@ its displayed summary when present, event time, and missing-task state.
|
|
|
358
358
|
|
|
359
359
|
## Task-log migration
|
|
360
360
|
|
|
361
|
-
`workspace migrate` MUST explicitly convert every supported schema-4/5 record
|
|
362
|
-
under the shared lock.
|
|
363
|
-
|
|
364
|
-
complete source and complete schema-
|
|
361
|
+
`workspace migrate` MUST explicitly convert every supported schema-4/5/6 record
|
|
362
|
+
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,
|
|
365
365
|
create and read back an exclusive recovery backup, atomically replace the log,
|
|
366
|
-
and validate the installed result. A fully schema-
|
|
366
|
+
and validate the installed result. A fully schema-7 log MUST be an idempotent
|
|
367
367
|
no-op without another backup. Invalid/unsupported input MUST remain untouched;
|
|
368
368
|
failures after backup creation MUST report the backup path and MUST never
|
|
369
369
|
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-
|
|
92
|
+
W->>W: Lock, validate, append schema-7 snapshot
|
|
93
93
|
W-->>M: working link-pending task
|
|
94
94
|
M-->>D: task
|
|
95
95
|
D->>C: Create executor with marked instruction
|
|
@@ -116,8 +116,8 @@ semantic callbacks.
|
|
|
116
116
|
## State reporting
|
|
117
117
|
|
|
118
118
|
The executor obtains the turn identity from an exact native read of its own
|
|
119
|
-
linked task. `report_state` records live turn state
|
|
120
|
-
|
|
119
|
+
linked task. `report_state` records live turn state as a paired request/result
|
|
120
|
+
timeline.
|
|
121
121
|
|
|
122
122
|
```mermaid
|
|
123
123
|
sequenceDiagram
|
|
@@ -128,15 +128,15 @@ sequenceDiagram
|
|
|
128
128
|
participant W as workspace.js
|
|
129
129
|
E->>C: Exact read of linked executor
|
|
130
130
|
C-->>E: Current turn ID
|
|
131
|
-
E->>M: report_state(..., working,
|
|
131
|
+
E->>M: report_state(..., working, requestSummary)
|
|
132
132
|
M->>W: reportTaskState()
|
|
133
|
-
W->>W:
|
|
133
|
+
W->>W: Append turn with request and null result
|
|
134
134
|
E->>E: Work, finish, or reach semantic decision
|
|
135
135
|
E->>M: report_state(..., semantic status, summary)
|
|
136
136
|
M->>W: reportTaskState()
|
|
137
137
|
W->>W: Lock and validate identity and freshness
|
|
138
138
|
alt Same current working turn
|
|
139
|
-
W->>W:
|
|
139
|
+
W->>W: Fill that turn's result and derive compatibility results
|
|
140
140
|
W-->>M: Updated task
|
|
141
141
|
M-->>E: Recorded result
|
|
142
142
|
else Same turn and same result
|
|
@@ -171,11 +171,11 @@ sequenceDiagram
|
|
|
171
171
|
U->>E: Provide decision
|
|
172
172
|
E->>C: Read exact executor after follow-up
|
|
173
173
|
C-->>E: turnB
|
|
174
|
-
E->>M: report_state(..., turnB, working,
|
|
174
|
+
E->>M: report_state(..., turnB, working, requestB)
|
|
175
175
|
M->>W: reportTaskState()
|
|
176
|
-
W->>W: Require turnB greater and
|
|
177
|
-
W-->>M: working snapshot plus
|
|
178
|
-
M-->>E: working snapshot plus
|
|
176
|
+
W->>W: Require turnB greater and append request B
|
|
177
|
+
W-->>M: working snapshot plus paired timeline
|
|
178
|
+
M-->>E: working snapshot plus paired timeline
|
|
179
179
|
E->>M: report_state(..., turnB, completed, summaryB)
|
|
180
180
|
M->>W: reportTaskState()
|
|
181
181
|
W-->>M: completed snapshot plus result B
|
|
@@ -263,7 +263,7 @@ sequenceDiagram
|
|
|
263
263
|
F-->>D: Filesystem change
|
|
264
264
|
D->>F: Bounded read from one descriptor
|
|
265
265
|
D->>D: Validate current schema and sort
|
|
266
|
-
D-->>B: Compact SSE snapshot
|
|
266
|
+
D-->>B: Compact SSE snapshot with latestTurn only
|
|
267
267
|
B->>D: GET task detail on demand
|
|
268
268
|
D-->>B: Full validated history
|
|
269
269
|
B->>D: Open task action
|
|
@@ -279,8 +279,8 @@ The dashboard binds to `127.0.0.1`, has no shared session state, limits task
|
|
|
279
279
|
count, file size, result count, and display fields, and checks origin/authority
|
|
280
280
|
for stateful local actions. Its bounded identity endpoint contains no task data
|
|
281
281
|
or secrets. The monitor already validates the complete log, but
|
|
282
|
-
snapshot/SSE list projections omit `results` so repeated
|
|
283
|
-
unnecessary history. A read-only per-task endpoint returns full
|
|
282
|
+
snapshot/SSE list projections omit `turns` and derived `results` so repeated
|
|
283
|
+
updates do not resend unnecessary history. A read-only per-task endpoint returns the full timeline only
|
|
284
284
|
when the dialog opens. Historical project paths are untrusted until matched
|
|
285
285
|
against current configuration.
|
|
286
286
|
|
|
@@ -306,14 +306,14 @@ all, and ordinary rerendering do not re-announce retained history. Toast action
|
|
|
306
306
|
labels remain concise while `aria-describedby` connects the visible summary,
|
|
307
307
|
event time, and missing-task explanation for assistive technology.
|
|
308
308
|
|
|
309
|
-
## Schema 4/5 migration
|
|
309
|
+
## Schema 4/5/6 migration
|
|
310
310
|
|
|
311
311
|
`taskchef workspace migrate` acquires the same workspace lock as lifecycle
|
|
312
|
-
writers, validates the complete legacy log, converts
|
|
313
|
-
|
|
312
|
+
writers, validates the complete legacy log, converts schema-4/5/6 results into
|
|
313
|
+
request-unknown completed turns and preserves a newer working turn, then validates the
|
|
314
314
|
complete candidate. Before replacement it writes and reads back an exclusive
|
|
315
|
-
`tasks.jsonl.pre-
|
|
316
|
-
validated again. A second run sees only schema
|
|
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
|
|
317
317
|
another backup. Unsupported or malformed input fails before backup/rewrite;
|
|
318
318
|
after a later filesystem failure, the reported backup is the recovery source.
|
|
319
319
|
|
|
@@ -330,7 +330,8 @@ summary is cryptographically authenticated; this is a local single-user trust
|
|
|
330
330
|
model. Managed files, instructions, project snapshots, MCP inputs, and dashboard
|
|
331
331
|
requests are validated at every action boundary.
|
|
332
332
|
|
|
333
|
-
Configuration schema 2 and task schemas 4, 5, and
|
|
334
|
-
are read/migration compatibility until an explicit migration or lifecycle
|
|
335
|
-
mutation upgrades each record to schema
|
|
336
|
-
|
|
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
|
|
337
|
+
are rejected without rewrite.
|
package/package.json
CHANGED
|
@@ -31,7 +31,8 @@ Complete this lifecycle setup before substantive assignment work:
|
|
|
31
31
|
3. Read this exact Codex thread natively and obtain the current turn ID. Do not
|
|
32
32
|
infer it or reuse an earlier turn ID.
|
|
33
33
|
4. Call TaskChef `report_state` with the marked task ID, self-linked thread ID,
|
|
34
|
-
current turn ID, `status: working`,
|
|
34
|
+
current turn ID, `status: working`, an omitted or null result summary, and a
|
|
35
|
+
concise `requestSummary` describing this turn's assignment or follow-up.
|
|
35
36
|
|
|
36
37
|
If `CODEX_THREAD_ID`, exact native thread reading, or a required TaskChef tool
|
|
37
38
|
is unavailable, or if linking or the working-state report fails, report the
|
|
@@ -56,7 +57,7 @@ turn, run the start lifecycle with its new current turn ID and report that
|
|
|
56
57
|
turn's actual outcome. Say reporting failures visibly instead of claiming a
|
|
57
58
|
tracked outcome.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
Request and result summaries must omit secrets, transcripts, raw command output, hidden reasoning,
|
|
60
61
|
and unnecessary personal data. Identical lifecycle retries are safe; never
|
|
61
62
|
replace a same-turn report with different content or let an older turn
|
|
62
63
|
overwrite newer state.
|
|
@@ -44,15 +44,14 @@ 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
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
semantic result into `results` and `lastResult` without rewriting their log line.
|
|
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.
|
|
50
|
+
`results` and `lastResult` remain derived compatibility projections; do not
|
|
51
|
+
pair the latest request with an earlier result. Treat a failed result with
|
|
52
|
+
null thread and turn IDs as a fresh executor-creation failure. Schema 4/5/6
|
|
53
|
+
snapshots normalize their semantic results into legacy turns with a null
|
|
54
|
+
request summary without rewriting their log line.
|
|
56
55
|
No live read is possible or needed for that creation failure. When identity is certain and
|
|
57
56
|
metadata says the thread is inactive, trust the latest semantic result by
|
|
58
57
|
default in a broad overview unless a newer working state makes it historical.
|
package/src/cli.js
CHANGED
|
@@ -183,10 +183,10 @@ function taskDetails(task) {
|
|
|
183
183
|
`Updated by: ${singleLineDetail(task.updatedBy ?? "-")}`,
|
|
184
184
|
`Task ID: ${singleLineDetail(task.id)}`,
|
|
185
185
|
`Thread ID: ${singleLineDetail(task.threadId ?? "-")}`,
|
|
186
|
-
`
|
|
187
|
-
"
|
|
188
|
-
...[...task.
|
|
189
|
-
`- ${singleLineDetail(
|
|
186
|
+
`Turn count: ${task.turns.length}`,
|
|
187
|
+
"Activity timeline (newest first):",
|
|
188
|
+
...[...task.turns].reverse().map((turn) => (
|
|
189
|
+
`- ${singleLineDetail(turn.startedAt)} | ${singleLineDetail(turn.result?.status ?? "working")} | turn ${singleLineDetail(turn.turnId ?? "-")} | request: ${singleLineDetail(turn.requestSummary ?? "not recorded")} | result: ${singleLineDetail(turn.result?.summary ?? "in progress")}`
|
|
190
190
|
)),
|
|
191
191
|
"Instruction:",
|
|
192
192
|
task.instruction,
|
package/src/dashboard/app.js
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
dismissNotification,
|
|
4
4
|
findCurrentTask,
|
|
5
5
|
KNOWN_TASK_STATUSES,
|
|
6
|
+
latestTurnPresentation,
|
|
7
|
+
mergeProjectedTurns,
|
|
6
8
|
nextDateFilterRefreshDelay,
|
|
7
9
|
notificationDismissLabel,
|
|
8
10
|
notificationOpenLabel,
|
|
@@ -222,34 +224,44 @@ function detailRow(term, value) {
|
|
|
222
224
|
return [dt, dd];
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
function
|
|
226
|
-
if (
|
|
227
|
+
function turnTimeline(task) {
|
|
228
|
+
if (task.turns.length === 0) {
|
|
227
229
|
const empty = document.createElement("p");
|
|
228
230
|
empty.className = "result-history-empty";
|
|
229
|
-
empty.textContent = "No
|
|
231
|
+
empty.textContent = "No executor turn has been reported yet.";
|
|
230
232
|
return [empty];
|
|
231
233
|
}
|
|
232
|
-
return [...
|
|
234
|
+
return [...task.turns].reverse().map((turn, index) => {
|
|
233
235
|
const item = document.createElement("article");
|
|
234
236
|
item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
|
|
235
237
|
const header = document.createElement("div");
|
|
236
238
|
header.className = "result-history-header";
|
|
237
239
|
const status = document.createElement("span");
|
|
238
|
-
|
|
239
|
-
status.
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
const turnStatus = turn.result?.status ?? "working";
|
|
241
|
+
status.className = `status status-${turnStatus}`;
|
|
242
|
+
status.textContent = turnStatus.replaceAll("_", " ");
|
|
243
|
+
const turnKey = turn.turnId ?? `no-turn:${index}`;
|
|
244
|
+
const timestamp = timestampControl(turn.result?.updatedAt ?? turn.startedAt, {
|
|
245
|
+
accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
|
|
246
|
+
key: `detail:${task.id}:turn:${turnKey}`,
|
|
244
247
|
});
|
|
245
248
|
header.append(status, timestamp);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
const requestLabel = document.createElement("h4");
|
|
250
|
+
requestLabel.textContent = "Request";
|
|
251
|
+
const request = document.createElement("p");
|
|
252
|
+
request.className = "preserve-lines";
|
|
253
|
+
request.textContent = turn.requestSummary ?? "Request not recorded by this TaskChef version.";
|
|
254
|
+
const resultLabel = document.createElement("h4");
|
|
255
|
+
resultLabel.textContent = "Result";
|
|
256
|
+
const result = document.createElement("p");
|
|
257
|
+
result.className = "preserve-lines";
|
|
258
|
+
result.textContent = turn.result?.summary ?? "In progress";
|
|
259
|
+
const turnMetadata = document.createElement("p");
|
|
260
|
+
turnMetadata.className = "result-history-turn";
|
|
261
|
+
turnMetadata.textContent = turn.turnId
|
|
262
|
+
? `Turn ${turn.turnId}`
|
|
263
|
+
: "No turn ID (creation failure)";
|
|
264
|
+
item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
|
|
253
265
|
return item;
|
|
254
266
|
});
|
|
255
267
|
}
|
|
@@ -260,12 +272,13 @@ function renderDialog(task) {
|
|
|
260
272
|
: null;
|
|
261
273
|
const detailedTask = {
|
|
262
274
|
...task,
|
|
275
|
+
turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
|
|
263
276
|
results: task.results ?? preservedResults ?? [],
|
|
264
277
|
};
|
|
265
278
|
state.selectedTask = detailedTask;
|
|
266
279
|
elements.dialogProject.textContent = task.project.name;
|
|
267
280
|
elements.dialogTitle.textContent = task.title;
|
|
268
|
-
elements.dialogResults.replaceChildren(...
|
|
281
|
+
elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
|
|
269
282
|
elements.dialogInstruction.textContent = task.instruction;
|
|
270
283
|
elements.copyThreadId.disabled = !task.threadId;
|
|
271
284
|
elements.dialogMetadata.replaceChildren(
|
|
@@ -313,7 +326,7 @@ async function openDialog(task) {
|
|
|
313
326
|
}
|
|
314
327
|
} catch {
|
|
315
328
|
if (state.selectedTask?.id === task.id) {
|
|
316
|
-
showMessage("Task
|
|
329
|
+
showMessage("Task activity timeline is temporarily unavailable.");
|
|
317
330
|
}
|
|
318
331
|
}
|
|
319
332
|
}
|
|
@@ -337,7 +350,18 @@ function taskCard(task) {
|
|
|
337
350
|
project.textContent = task.project.name;
|
|
338
351
|
const summary = document.createElement("p");
|
|
339
352
|
summary.className = "task-summary";
|
|
340
|
-
|
|
353
|
+
const latest = latestTurnPresentation(task);
|
|
354
|
+
const requestLabel = document.createElement("strong");
|
|
355
|
+
requestLabel.textContent = "Request";
|
|
356
|
+
const request = document.createElement("span");
|
|
357
|
+
request.className = "preserve-lines";
|
|
358
|
+
request.textContent = latest.requestSummary;
|
|
359
|
+
const resultLabel = document.createElement("strong");
|
|
360
|
+
resultLabel.textContent = "Result";
|
|
361
|
+
const result = document.createElement("span");
|
|
362
|
+
result.className = "preserve-lines";
|
|
363
|
+
result.textContent = latest.resultSummary;
|
|
364
|
+
summary.replaceChildren(requestLabel, request, resultLabel, result);
|
|
341
365
|
const time = timestampControl(
|
|
342
366
|
task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
|
|
343
367
|
{
|
package/src/dashboard/index.html
CHANGED
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
<button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
|
|
95
95
|
</div>
|
|
96
96
|
<section>
|
|
97
|
-
<h3>
|
|
97
|
+
<h3>Activity timeline</h3>
|
|
98
98
|
<div id="dialog-results" class="result-history"></div>
|
|
99
99
|
</section>
|
|
100
100
|
<section>
|
package/src/dashboard/state.js
CHANGED
|
@@ -31,6 +31,37 @@ export function taskStatusLabel(task) {
|
|
|
31
31
|
return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function latestTurnPresentation(task) {
|
|
35
|
+
const turn = task.latestTurn ?? null;
|
|
36
|
+
const result = turn?.result ?? null;
|
|
37
|
+
const requestSummary = turn?.requestSummary
|
|
38
|
+
?? (turn ? "Request not recorded by this TaskChef version." : task.title);
|
|
39
|
+
return {
|
|
40
|
+
turnId: turn?.turnId ?? task.turnId ?? null,
|
|
41
|
+
startedAt: turn?.startedAt ?? task.updatedAt ?? task.createdAt ?? null,
|
|
42
|
+
requestSummary,
|
|
43
|
+
resultStatus: result?.status ?? (task.status === "working" ? "working" : task.status),
|
|
44
|
+
resultSummary: result?.summary
|
|
45
|
+
?? (task.status === "working"
|
|
46
|
+
? "In progress"
|
|
47
|
+
: task.lastResult?.summary ?? task.summary ?? "No result reported."),
|
|
48
|
+
resultUpdatedAt: result?.updatedAt ?? null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeProjectedTurns(task, preservedTurns = []) {
|
|
53
|
+
if (Array.isArray(task.turns)) return task.turns;
|
|
54
|
+
if (!task.latestTurn) return preservedTurns;
|
|
55
|
+
const turns = [...preservedTurns];
|
|
56
|
+
const lastIndex = turns.length - 1;
|
|
57
|
+
if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
|
|
58
|
+
turns[lastIndex] = task.latestTurn;
|
|
59
|
+
} else {
|
|
60
|
+
turns.push(task.latestTurn);
|
|
61
|
+
}
|
|
62
|
+
return turns;
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
export function notificationTitle(notification) {
|
|
35
66
|
return NOTIFICATION_TITLES.get(notification.event) ?? "Task updated";
|
|
36
67
|
}
|
package/src/dashboard/styles.css
CHANGED
|
@@ -79,7 +79,8 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
|
|
|
79
79
|
.task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
80
80
|
.task-project { margin: 3px 0 12px; font-size: 0.86rem; }
|
|
81
81
|
.task-title, .task-summary { overflow-wrap: anywhere; }
|
|
82
|
-
.task-summary { max-width: 78ch; margin-bottom: 14px; }
|
|
82
|
+
.task-summary { display: grid; max-width: 78ch; margin-bottom: 14px; gap: 3px; }
|
|
83
|
+
.task-summary strong { margin-top: 5px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
83
84
|
time, .timestamp-missing { font-size: 0.78rem; }
|
|
84
85
|
.timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
|
|
85
86
|
.timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
@@ -133,6 +134,7 @@ dialog section + section { margin-top: 24px; }
|
|
|
133
134
|
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
134
135
|
.result-history-item p { margin-bottom: 7px; }
|
|
135
136
|
.result-history-item p:last-child { margin-bottom: 0; }
|
|
137
|
+
.result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
136
138
|
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
137
139
|
.result-history-empty { margin: 0; }
|
|
138
140
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
package/src/dashboard.js
CHANGED
|
@@ -139,6 +139,17 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
139
139
|
boundedText(task.turnId, 512, `${name} turn ID`);
|
|
140
140
|
boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
|
|
141
141
|
boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
|
|
142
|
+
boundedText(task.latestTurn?.requestSummary, 1_000, `${name} latest request summary`);
|
|
143
|
+
boundedText(task.latestTurn?.turnId, 512, `${name} latest turn ID`);
|
|
144
|
+
const turns = task.turns ?? [];
|
|
145
|
+
if (turns.length > 10_000) {
|
|
146
|
+
throw new Error(`${name} has too many turns for the dashboard`);
|
|
147
|
+
}
|
|
148
|
+
for (const [turnIndex, turn] of turns.entries()) {
|
|
149
|
+
boundedText(turn.requestSummary, 1_000, `${name} turn ${turnIndex + 1} request summary`);
|
|
150
|
+
boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
|
|
151
|
+
boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
|
|
152
|
+
}
|
|
142
153
|
const results = task.results ?? [];
|
|
143
154
|
if (results.length > 10_000) {
|
|
144
155
|
throw new Error(`${name} has too many results for the dashboard`);
|
|
@@ -160,7 +171,7 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
function taskListProjection(task) {
|
|
163
|
-
const { results: _results, ...projection } = task;
|
|
174
|
+
const { turns: _turns, results: _results, ...projection } = task;
|
|
164
175
|
return projection;
|
|
165
176
|
}
|
|
166
177
|
|
package/src/delegation.js
CHANGED
|
@@ -5,7 +5,7 @@ export const EXECUTOR_OWNERSHIP_PARAGRAPH = "This task owns the delegated assign
|
|
|
5
5
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
6
6
|
export const EXECUTOR_LINK_PARAGRAPH = "Before any other work, read this executor's own durable Codex thread ID from the current task's CODEX_THREAD_ID environment value and call the TaskChef link_task MCP tool with that thread ID and the marked TaskChef task ID. Never use CODEX_SESSION_ID or the parent or delegator thread ID. If linking fails, CODEX_THREAD_ID is unavailable, or the tool is unavailable, report the failure visibly and retry on a later turn; do not guess an identity or continue substantive work while the task is link-pending.";
|
|
7
7
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
8
|
-
export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working,
|
|
8
|
+
export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working, summary omitted or null, and a concise requestSummary for this turn. link_task remains the first TaskChef action on the initial turn; do not report working before identity is linked. Never reuse a prior turn ID after a follow-up.";
|
|
9
9
|
/** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
|
|
10
10
|
export const EXECUTOR_RESULT_PARAGRAPH = "Before ending, read this exact Codex thread again and call TaskChef report_state for the same current working turn with status completed, needs_input, or failed and a concise summary. Use needs_input only for a semantic decision or information the user must provide; a native approval prompt is live Codex state, not a TaskChef result. Do not include secrets, transcripts, or raw command output.";
|
|
11
11
|
export const EXECUTOR_SKILL_INVOCATION = "Use $taskchef-executor to execute and report this delegated TaskChef assignment.";
|
package/src/mcp.js
CHANGED
|
@@ -21,7 +21,7 @@ const projectSchema = z.object({
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const taskSchema = z.object({
|
|
24
|
-
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
|
|
24
|
+
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
|
|
25
25
|
id: z.string(),
|
|
26
26
|
project: projectSchema,
|
|
27
27
|
title: z.string(),
|
|
@@ -33,6 +33,26 @@ const taskSchema = z.object({
|
|
|
33
33
|
turnId: z.string().nullable(),
|
|
34
34
|
updatedAt: z.string(),
|
|
35
35
|
updatedBy: z.enum(["dispatcher", "mcp"]),
|
|
36
|
+
turns: z.array(z.object({
|
|
37
|
+
turnId: z.string().nullable(),
|
|
38
|
+
requestSummary: z.string().nullable(),
|
|
39
|
+
startedAt: z.string(),
|
|
40
|
+
result: z.object({
|
|
41
|
+
status: z.enum(["needs_input", "completed", "failed"]),
|
|
42
|
+
summary: z.string(),
|
|
43
|
+
updatedAt: z.string(),
|
|
44
|
+
}).nullable(),
|
|
45
|
+
})),
|
|
46
|
+
latestTurn: z.object({
|
|
47
|
+
turnId: z.string().nullable(),
|
|
48
|
+
requestSummary: z.string().nullable(),
|
|
49
|
+
startedAt: z.string(),
|
|
50
|
+
result: z.object({
|
|
51
|
+
status: z.enum(["needs_input", "completed", "failed"]),
|
|
52
|
+
summary: z.string(),
|
|
53
|
+
updatedAt: z.string(),
|
|
54
|
+
}).nullable(),
|
|
55
|
+
}).nullable(),
|
|
36
56
|
results: z.array(z.object({
|
|
37
57
|
status: z.enum(["needs_input", "completed", "failed"]),
|
|
38
58
|
summary: z.string(),
|
|
@@ -203,13 +223,14 @@ export function createTaskChefMcpServer({
|
|
|
203
223
|
{
|
|
204
224
|
title: "Report TaskChef state",
|
|
205
225
|
description:
|
|
206
|
-
"Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary. Exact retries are idempotent; stale or mismatched turns are rejected.",
|
|
226
|
+
"Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null and a concise requestSummary. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary and requestSummary omitted. Exact retries are idempotent; stale or mismatched turns are rejected.",
|
|
207
227
|
inputSchema: {
|
|
208
228
|
taskId: z.string().min(1),
|
|
209
229
|
threadId: z.string().min(1).nullable(),
|
|
210
230
|
turnId: z.string().min(1).max(256).nullable(),
|
|
211
231
|
status: z.enum(["working", "needs_input", "completed", "failed"]),
|
|
212
232
|
summary: z.string().min(1).max(2_000).nullable().optional(),
|
|
233
|
+
requestSummary: z.string().min(1).max(1_000).nullable().optional(),
|
|
213
234
|
},
|
|
214
235
|
outputSchema: { task: taskSchema },
|
|
215
236
|
annotations: {
|
package/src/workspace.js
CHANGED
|
@@ -38,8 +38,8 @@ const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
|
38
38
|
const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
|
|
39
39
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
40
40
|
const CURRENT_CONFIG_SCHEMA_VERSION = 2;
|
|
41
|
-
const CURRENT_TASK_SCHEMA_VERSION =
|
|
42
|
-
const PREVIOUS_TASK_SCHEMA_VERSION =
|
|
41
|
+
const CURRENT_TASK_SCHEMA_VERSION = 7;
|
|
42
|
+
const PREVIOUS_TASK_SCHEMA_VERSION = 6;
|
|
43
43
|
const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
|
|
44
44
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
45
45
|
const PROJECT_FIELDS = new Set([
|
|
@@ -65,7 +65,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
|
|
|
65
65
|
"updatedBy",
|
|
66
66
|
]);
|
|
67
67
|
const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
|
|
68
|
-
const
|
|
68
|
+
const SCHEMA_6_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
|
|
69
|
+
const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
|
|
69
70
|
const RECORD_DISPATCH_FIELDS = new Set([
|
|
70
71
|
"id",
|
|
71
72
|
"project",
|
|
@@ -77,7 +78,10 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
|
|
|
77
78
|
const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
|
|
78
79
|
const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
|
|
79
80
|
const MAX_RESULT_SUMMARY_LENGTH = 2_000;
|
|
81
|
+
const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
|
|
80
82
|
const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
|
|
83
|
+
const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
|
|
84
|
+
const TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
|
|
81
85
|
|
|
82
86
|
function requireExactFields(value, fields, name) {
|
|
83
87
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -224,7 +228,7 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
|
224
228
|
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
225
229
|
const content = await readFile(dispatchPath, "utf8");
|
|
226
230
|
const appended = dispatches
|
|
227
|
-
.map((dispatch) => `${JSON.stringify(
|
|
231
|
+
.map((dispatch) => `${JSON.stringify(schema7Task(dispatch))}\n`)
|
|
228
232
|
.join("");
|
|
229
233
|
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
230
234
|
}
|
|
@@ -684,6 +688,7 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
684
688
|
async function validateDispatchShape(dispatch, name = "task") {
|
|
685
689
|
const supportedVersions = [
|
|
686
690
|
FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
|
|
691
|
+
5,
|
|
687
692
|
PREVIOUS_TASK_SCHEMA_VERSION,
|
|
688
693
|
CURRENT_TASK_SCHEMA_VERSION,
|
|
689
694
|
];
|
|
@@ -695,6 +700,8 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
695
700
|
dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
696
701
|
? DISPATCH_FIELDS
|
|
697
702
|
: dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
|
|
703
|
+
? SCHEMA_6_DISPATCH_FIELDS
|
|
704
|
+
: dispatch.schemaVersion === 5
|
|
698
705
|
? SCHEMA_5_DISPATCH_FIELDS
|
|
699
706
|
: STATEFUL_DISPATCH_FIELDS,
|
|
700
707
|
name,
|
|
@@ -735,20 +742,77 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
735
742
|
}
|
|
736
743
|
return normalizedResult;
|
|
737
744
|
};
|
|
738
|
-
|
|
745
|
+
const normalizeTurnResult = (result, resultName) => {
|
|
746
|
+
if (result === null) return null;
|
|
747
|
+
requireExactFields(result, TURN_RESULT_FIELDS, resultName);
|
|
748
|
+
const normalizedResult = {
|
|
749
|
+
status: requireEnum(result.status, RESULT_STATUSES, `${resultName}.status`),
|
|
750
|
+
summary: optionalString(result.summary, `${resultName}.summary`, {
|
|
751
|
+
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
752
|
+
}),
|
|
753
|
+
updatedAt: requireTimestamp(result.updatedAt, `${resultName}.updatedAt`),
|
|
754
|
+
};
|
|
755
|
+
if (normalizedResult.summary === null) {
|
|
756
|
+
throw new Error(`${resultName}.summary must be a non-empty string`);
|
|
757
|
+
}
|
|
758
|
+
return normalizedResult;
|
|
759
|
+
};
|
|
760
|
+
const normalizeTurn = (turn, turnName) => {
|
|
761
|
+
requireExactFields(turn, TURN_FIELDS, turnName);
|
|
762
|
+
return {
|
|
763
|
+
turnId: optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 }),
|
|
764
|
+
requestSummary: optionalString(turn.requestSummary, `${turnName}.requestSummary`, {
|
|
765
|
+
maxLength: MAX_REQUEST_SUMMARY_LENGTH,
|
|
766
|
+
}),
|
|
767
|
+
startedAt: requireTimestamp(turn.startedAt, `${turnName}.startedAt`),
|
|
768
|
+
result: normalizeTurnResult(turn.result, `${turnName}.result`),
|
|
769
|
+
};
|
|
770
|
+
};
|
|
771
|
+
let legacyResults = [];
|
|
772
|
+
let turns = [];
|
|
739
773
|
if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
|
|
774
|
+
if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
|
|
775
|
+
turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
|
|
776
|
+
} else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
|
|
740
777
|
if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
|
|
741
|
-
|
|
778
|
+
legacyResults = dispatch.results.map((result, index) => (
|
|
742
779
|
normalizeResult(result, `${name}.results[${index}]`)
|
|
743
780
|
));
|
|
744
|
-
} else if (dispatch.schemaVersion ===
|
|
781
|
+
} else if (dispatch.schemaVersion === 5) {
|
|
745
782
|
if (dispatch.lastResult !== null) {
|
|
746
|
-
|
|
783
|
+
legacyResults = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
|
|
747
784
|
}
|
|
748
785
|
} else if (RESULT_STATUSES.has(status)) {
|
|
749
|
-
|
|
786
|
+
legacyResults = [{ status, summary, turnId, updatedAt }];
|
|
750
787
|
}
|
|
788
|
+
if (dispatch.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
|
|
789
|
+
turns = legacyResults.map((result) => ({
|
|
790
|
+
turnId: result.turnId,
|
|
791
|
+
requestSummary: null,
|
|
792
|
+
startedAt: result.updatedAt,
|
|
793
|
+
result: {
|
|
794
|
+
status: result.status,
|
|
795
|
+
summary: result.summary,
|
|
796
|
+
updatedAt: result.updatedAt,
|
|
797
|
+
},
|
|
798
|
+
}));
|
|
799
|
+
if (
|
|
800
|
+
status === "working"
|
|
801
|
+
&& turnId !== null
|
|
802
|
+
&& (
|
|
803
|
+
turns.at(-1)?.turnId !== turnId
|
|
804
|
+
|| turns.at(-1)?.result !== null
|
|
805
|
+
)
|
|
806
|
+
) {
|
|
807
|
+
turns.push({ turnId, requestSummary: null, startedAt: updatedAt, result: null });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const results = turns.flatMap((turn) => turn.result === null ? [] : [{
|
|
811
|
+
...turn.result,
|
|
812
|
+
turnId: turn.turnId,
|
|
813
|
+
}]);
|
|
751
814
|
const lastResult = results.at(-1) ?? null;
|
|
815
|
+
const latestTurn = turns.at(-1) ?? null;
|
|
752
816
|
const normalized = {
|
|
753
817
|
schemaVersion: dispatch.schemaVersion,
|
|
754
818
|
id,
|
|
@@ -764,6 +828,8 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
764
828
|
turnId,
|
|
765
829
|
updatedAt,
|
|
766
830
|
updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
|
|
831
|
+
turns,
|
|
832
|
+
latestTurn,
|
|
767
833
|
results,
|
|
768
834
|
lastResult,
|
|
769
835
|
};
|
|
@@ -773,14 +839,20 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
773
839
|
if (normalized.turnId !== null) {
|
|
774
840
|
normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
|
|
775
841
|
}
|
|
776
|
-
for (const [index,
|
|
777
|
-
if (
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
`${name}.
|
|
842
|
+
for (const [index, turn] of normalized.turns.entries()) {
|
|
843
|
+
if (turn.turnId != null) {
|
|
844
|
+
turn.turnId = normalizeCodexThreadId(
|
|
845
|
+
turn.turnId,
|
|
846
|
+
`${name}.turns[${index}].turnId`,
|
|
781
847
|
);
|
|
782
848
|
}
|
|
783
849
|
}
|
|
850
|
+
normalized.results = normalized.turns.flatMap((turn) => turn.result === null ? [] : [{
|
|
851
|
+
...turn.result,
|
|
852
|
+
turnId: turn.turnId,
|
|
853
|
+
}]);
|
|
854
|
+
normalized.lastResult = normalized.results.at(-1) ?? null;
|
|
855
|
+
normalized.latestTurn = normalized.turns.at(-1) ?? null;
|
|
784
856
|
}
|
|
785
857
|
if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
|
|
786
858
|
if (normalized.threadId === null) {
|
|
@@ -814,7 +886,7 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
814
886
|
if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
|
|
815
887
|
throw new Error(`${name}.summary is required for status ${normalized.status}`);
|
|
816
888
|
}
|
|
817
|
-
if (normalized.schemaVersion >=
|
|
889
|
+
if (normalized.schemaVersion >= 5) {
|
|
818
890
|
if (RESULT_STATUSES.has(normalized.status)) {
|
|
819
891
|
if (
|
|
820
892
|
normalized.lastResult === null
|
|
@@ -850,32 +922,76 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
850
922
|
throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
|
|
851
923
|
}
|
|
852
924
|
}
|
|
853
|
-
|
|
925
|
+
if (normalized.status === "working") {
|
|
926
|
+
if (normalized.turnId === null) {
|
|
927
|
+
if (normalized.turns.length !== 0) {
|
|
928
|
+
throw new Error(`${name}.turns must be empty before the first working turn`);
|
|
929
|
+
}
|
|
930
|
+
} else if (
|
|
931
|
+
normalized.latestTurn === null
|
|
932
|
+
|| normalized.latestTurn.turnId !== normalized.turnId
|
|
933
|
+
|| normalized.latestTurn.result !== null
|
|
934
|
+
) {
|
|
935
|
+
throw new Error(`${name}.latestTurn must match the current working turn`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (RESULT_STATUSES.has(normalized.status)) {
|
|
939
|
+
if (
|
|
940
|
+
normalized.latestTurn === null
|
|
941
|
+
|| normalized.latestTurn.turnId !== normalized.turnId
|
|
942
|
+
|| normalized.latestTurn.result === null
|
|
943
|
+
|| normalized.latestTurn.result.status !== normalized.status
|
|
944
|
+
|| normalized.latestTurn.result.summary !== normalized.summary
|
|
945
|
+
|| normalized.latestTurn.result.updatedAt !== normalized.updatedAt
|
|
946
|
+
) {
|
|
947
|
+
throw new Error(`${name}.latestTurn must match the current semantic state`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const seenTurnIds = new Set();
|
|
854
951
|
const nullTurnKey = Symbol("null turn");
|
|
855
|
-
for (const [index,
|
|
856
|
-
const turnKey =
|
|
857
|
-
|
|
858
|
-
|
|
952
|
+
for (const [index, turn] of normalized.turns.entries()) {
|
|
953
|
+
const turnKey = turn.turnId ?? nullTurnKey;
|
|
954
|
+
const isLegacyOpaqueWorkingReuse = (
|
|
955
|
+
!isSelfLinkingRecord
|
|
956
|
+
&& normalized.status === "working"
|
|
957
|
+
&& index === normalized.turns.length - 1
|
|
958
|
+
&& turn.result === null
|
|
959
|
+
&& index > 0
|
|
960
|
+
&& normalized.turns[index - 1].turnId === turn.turnId
|
|
961
|
+
&& normalized.turns[index - 1].result !== null
|
|
962
|
+
);
|
|
963
|
+
if (seenTurnIds.has(turnKey) && !isLegacyOpaqueWorkingReuse) {
|
|
964
|
+
throw new Error(`${name}.turns contains duplicate turnId: ${turn.turnId ?? "null"}`);
|
|
859
965
|
}
|
|
860
|
-
|
|
861
|
-
if (Date.parse(
|
|
862
|
-
throw new Error(`${name}.
|
|
966
|
+
seenTurnIds.add(turnKey);
|
|
967
|
+
if (Date.parse(turn.startedAt) < Date.parse(normalized.createdAt)) {
|
|
968
|
+
throw new Error(`${name}.turns[${index}].startedAt must not be earlier than createdAt`);
|
|
863
969
|
}
|
|
864
|
-
if (Date.parse(
|
|
865
|
-
throw new Error(`${name}.
|
|
970
|
+
if (Date.parse(turn.startedAt) > Date.parse(normalized.updatedAt)) {
|
|
971
|
+
throw new Error(`${name}.turns[${index}].startedAt must not be later than updatedAt`);
|
|
866
972
|
}
|
|
867
973
|
if (
|
|
868
974
|
index > 0
|
|
869
|
-
&& Date.parse(
|
|
975
|
+
&& Date.parse(turn.startedAt) < Date.parse(normalized.turns[index - 1].startedAt)
|
|
870
976
|
) {
|
|
871
|
-
throw new Error(`${name}.
|
|
977
|
+
throw new Error(`${name}.turns must be ordered by startedAt`);
|
|
872
978
|
}
|
|
873
979
|
if (
|
|
874
980
|
isSelfLinkingRecord
|
|
875
981
|
&& index > 0
|
|
876
|
-
&&
|
|
982
|
+
&& turn.turnId <= normalized.turns[index - 1].turnId
|
|
877
983
|
) {
|
|
878
|
-
throw new Error(`${name}.
|
|
984
|
+
throw new Error(`${name}.turns must be ordered by turnId`);
|
|
985
|
+
}
|
|
986
|
+
if (turn.result !== null) {
|
|
987
|
+
if (Date.parse(turn.result.updatedAt) < Date.parse(turn.startedAt)) {
|
|
988
|
+
throw new Error(`${name}.turns[${index}].result.updatedAt must not be earlier than startedAt`);
|
|
989
|
+
}
|
|
990
|
+
if (Date.parse(turn.result.updatedAt) > Date.parse(normalized.updatedAt)) {
|
|
991
|
+
throw new Error(`${name}.turns[${index}].result.updatedAt must not be later than updatedAt`);
|
|
992
|
+
}
|
|
993
|
+
} else if (index !== normalized.turns.length - 1) {
|
|
994
|
+
throw new Error(`${name}.turns may contain an unfinished turn only at the end`);
|
|
879
995
|
}
|
|
880
996
|
}
|
|
881
997
|
if (
|
|
@@ -960,12 +1076,17 @@ export async function parseTaskLogContent(workspaceRoot, content) {
|
|
|
960
1076
|
return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
|
|
961
1077
|
}
|
|
962
1078
|
|
|
963
|
-
function
|
|
964
|
-
const {
|
|
1079
|
+
function schema7Task(dispatch, patch = {}) {
|
|
1080
|
+
const {
|
|
1081
|
+
latestTurn: _latestTurn,
|
|
1082
|
+
results: _results,
|
|
1083
|
+
lastResult: _lastResult,
|
|
1084
|
+
...persisted
|
|
1085
|
+
} = dispatch;
|
|
965
1086
|
return {
|
|
966
1087
|
...persisted,
|
|
967
1088
|
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
968
|
-
|
|
1089
|
+
turns: dispatch.turns ?? [],
|
|
969
1090
|
...patch,
|
|
970
1091
|
};
|
|
971
1092
|
}
|
|
@@ -994,13 +1115,13 @@ export async function migrateTaskLog(workspaceRoot, {
|
|
|
994
1115
|
backupPath: null,
|
|
995
1116
|
};
|
|
996
1117
|
}
|
|
997
|
-
const lines = records.map((record) => JSON.stringify(
|
|
1118
|
+
const lines = records.map((record) => JSON.stringify(schema7Task(record.normalized)));
|
|
998
1119
|
const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
999
1120
|
await parseDispatchRecordsUnlocked(root, migrated);
|
|
1000
1121
|
const timestamp = requireTimestamp(now(), "migration timestamp")
|
|
1001
1122
|
.replaceAll(":", "-")
|
|
1002
1123
|
.replaceAll(".", "-");
|
|
1003
|
-
const backupPath = `${dispatchPath}.pre-
|
|
1124
|
+
const backupPath = `${dispatchPath}.pre-v7-${timestamp}-${randomUUID()}.bak`;
|
|
1004
1125
|
await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
1005
1126
|
if (await readFile(backupPath, "utf8") !== original) {
|
|
1006
1127
|
throw new Error(`task log backup validation failed: ${backupPath}`);
|
|
@@ -1046,7 +1167,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
1046
1167
|
turnId: null,
|
|
1047
1168
|
updatedAt: createdAt,
|
|
1048
1169
|
updatedBy: "dispatcher",
|
|
1049
|
-
|
|
1170
|
+
turns: [],
|
|
1050
1171
|
});
|
|
1051
1172
|
const existing = await readDispatchesUnlocked(root);
|
|
1052
1173
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
@@ -1088,7 +1209,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1088
1209
|
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
1089
1210
|
}
|
|
1090
1211
|
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
1091
|
-
const canonical = await validateDispatchShape(
|
|
1212
|
+
const canonical = await validateDispatchShape(schema7Task(dispatch, {
|
|
1092
1213
|
threadId: durableThreadId,
|
|
1093
1214
|
updatedAt: transitionTimestamp(now, dispatch.updatedAt),
|
|
1094
1215
|
updatedBy: "mcp",
|
|
@@ -1117,7 +1238,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1117
1238
|
))) {
|
|
1118
1239
|
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
1119
1240
|
}
|
|
1120
|
-
const linked = await validateDispatchShape(
|
|
1241
|
+
const linked = await validateDispatchShape(schema7Task(dispatch, {
|
|
1121
1242
|
threadId: durableThreadId,
|
|
1122
1243
|
updatedAt: transitionTimestamp(now, dispatch.updatedAt),
|
|
1123
1244
|
updatedBy: "mcp",
|
|
@@ -1131,11 +1252,13 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
|
1131
1252
|
}
|
|
1132
1253
|
|
|
1133
1254
|
function dispatchLineWithState(dispatch, patch) {
|
|
1134
|
-
return JSON.stringify(
|
|
1255
|
+
return JSON.stringify(schema7Task(dispatch, patch));
|
|
1135
1256
|
}
|
|
1136
1257
|
|
|
1137
1258
|
function normalizeTaskStateInput(input, { allowWorking }) {
|
|
1138
|
-
const fields = new Set([
|
|
1259
|
+
const fields = new Set([
|
|
1260
|
+
"taskId", "threadId", "turnId", "status", "summary", "requestSummary",
|
|
1261
|
+
]);
|
|
1139
1262
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
1140
1263
|
throw new Error("task state must be an object");
|
|
1141
1264
|
}
|
|
@@ -1157,13 +1280,21 @@ function normalizeTaskStateInput(input, { allowWorking }) {
|
|
|
1157
1280
|
const summary = optionalString("summary" in input ? input.summary : null, "summary", {
|
|
1158
1281
|
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
1159
1282
|
});
|
|
1283
|
+
const requestSummary = optionalString(
|
|
1284
|
+
"requestSummary" in input ? input.requestSummary : null,
|
|
1285
|
+
"requestSummary",
|
|
1286
|
+
{ maxLength: MAX_REQUEST_SUMMARY_LENGTH },
|
|
1287
|
+
);
|
|
1160
1288
|
if (status === "working" && summary !== null) {
|
|
1161
1289
|
throw new Error("summary must be null while status is working");
|
|
1162
1290
|
}
|
|
1163
1291
|
if (status !== "working" && summary === null) {
|
|
1164
1292
|
throw new Error(`summary is required for status ${status}`);
|
|
1165
1293
|
}
|
|
1166
|
-
|
|
1294
|
+
if (status !== "working" && requestSummary !== null) {
|
|
1295
|
+
throw new Error(`requestSummary is accepted only for status working`);
|
|
1296
|
+
}
|
|
1297
|
+
return { id, threadId, turnId, status, summary, requestSummary };
|
|
1167
1298
|
}
|
|
1168
1299
|
|
|
1169
1300
|
function sameLastResult(lastResult, { status, summary, turnId }) {
|
|
@@ -1178,7 +1309,7 @@ async function reportTaskStateInternal(
|
|
|
1178
1309
|
input,
|
|
1179
1310
|
{ now, compatibilityAlias = false } = {},
|
|
1180
1311
|
) {
|
|
1181
|
-
const { id, threadId, turnId, status, summary } = normalizeTaskStateInput(input, {
|
|
1312
|
+
const { id, threadId, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
|
|
1182
1313
|
allowWorking: !compatibilityAlias,
|
|
1183
1314
|
});
|
|
1184
1315
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
@@ -1242,22 +1373,51 @@ async function reportTaskStateInternal(
|
|
|
1242
1373
|
}
|
|
1243
1374
|
}
|
|
1244
1375
|
if (status === "working") {
|
|
1245
|
-
|
|
1376
|
+
const sameWorkingTurn = dispatch.status === "working" && stateTurnId === dispatch.turnId;
|
|
1377
|
+
if (sameWorkingTurn) {
|
|
1378
|
+
const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
|
|
1379
|
+
if (
|
|
1380
|
+
requestSummary !== null
|
|
1381
|
+
&& storedRequest !== null
|
|
1382
|
+
&& requestSummary !== storedRequest
|
|
1383
|
+
) {
|
|
1384
|
+
throw new Error(`working turn already has a different requestSummary: ${id}`);
|
|
1385
|
+
}
|
|
1386
|
+
if (
|
|
1387
|
+
records[index].raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
1388
|
+
&& (requestSummary === null || storedRequest === requestSummary)
|
|
1389
|
+
) {
|
|
1390
|
+
return dispatch;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1246
1393
|
if (isSelfLinkingJourney) {
|
|
1247
|
-
if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
|
|
1394
|
+
if (!sameWorkingTurn && dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
|
|
1248
1395
|
throw new Error(`working turnId must be newer than the current task turnId: ${id}`);
|
|
1249
1396
|
}
|
|
1250
|
-
if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
|
|
1397
|
+
if (!sameWorkingTurn && dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
|
|
1251
1398
|
throw new Error(`working turnId must be newer than the last result turnId: ${id}`);
|
|
1252
1399
|
}
|
|
1253
1400
|
}
|
|
1254
|
-
const updatedAt =
|
|
1255
|
-
|
|
1401
|
+
const updatedAt = sameWorkingTurn
|
|
1402
|
+
? dispatch.updatedAt
|
|
1403
|
+
: transitionTimestamp(now, dispatch.updatedAt);
|
|
1404
|
+
const turns = sameWorkingTurn
|
|
1405
|
+
? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
|
|
1406
|
+
? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
|
|
1407
|
+
: turn)
|
|
1408
|
+
: [...dispatch.turns, {
|
|
1409
|
+
turnId: stateTurnId,
|
|
1410
|
+
requestSummary,
|
|
1411
|
+
startedAt: updatedAt,
|
|
1412
|
+
result: null,
|
|
1413
|
+
}];
|
|
1414
|
+
const updated = await validateDispatchShape(schema7Task(dispatch, {
|
|
1256
1415
|
status,
|
|
1257
1416
|
summary: null,
|
|
1258
1417
|
turnId: stateTurnId,
|
|
1259
1418
|
updatedAt,
|
|
1260
1419
|
updatedBy: "mcp",
|
|
1420
|
+
turns,
|
|
1261
1421
|
}));
|
|
1262
1422
|
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1263
1423
|
? dispatchLineWithState(updated, {})
|
|
@@ -1265,7 +1425,10 @@ async function reportTaskStateInternal(
|
|
|
1265
1425
|
await writeDispatchLinesAtomic(root, lines);
|
|
1266
1426
|
return updated;
|
|
1267
1427
|
}
|
|
1268
|
-
const
|
|
1428
|
+
const priorTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
|
|
1429
|
+
const priorTurnResult = priorTurn?.result === null || priorTurn === undefined
|
|
1430
|
+
? null
|
|
1431
|
+
: { ...priorTurn.result, turnId: priorTurn.turnId };
|
|
1269
1432
|
if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
|
|
1270
1433
|
return dispatch;
|
|
1271
1434
|
}
|
|
@@ -1287,14 +1450,28 @@ async function reportTaskStateInternal(
|
|
|
1287
1450
|
throw new Error(`task result must match the current working turnId: ${id}`);
|
|
1288
1451
|
}
|
|
1289
1452
|
const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
|
|
1290
|
-
const
|
|
1291
|
-
const
|
|
1453
|
+
const turnResult = { status, summary, updatedAt };
|
|
1454
|
+
const currentTurnIndex = dispatch.turns.findIndex((turn) => turn.turnId === stateTurnId);
|
|
1455
|
+
let turns;
|
|
1456
|
+
if (currentTurnIndex === -1) {
|
|
1457
|
+
turns = [...dispatch.turns, {
|
|
1458
|
+
turnId: stateTurnId,
|
|
1459
|
+
requestSummary: null,
|
|
1460
|
+
startedAt: updatedAt,
|
|
1461
|
+
result: turnResult,
|
|
1462
|
+
}];
|
|
1463
|
+
} else {
|
|
1464
|
+
turns = dispatch.turns.map((turn, turnIndex) => turnIndex === currentTurnIndex
|
|
1465
|
+
? { ...turn, result: turnResult }
|
|
1466
|
+
: turn);
|
|
1467
|
+
}
|
|
1468
|
+
const candidate = schema7Task(dispatch, {
|
|
1292
1469
|
status,
|
|
1293
1470
|
summary,
|
|
1294
1471
|
turnId: stateTurnId,
|
|
1295
1472
|
updatedAt,
|
|
1296
1473
|
updatedBy: "mcp",
|
|
1297
|
-
|
|
1474
|
+
turns,
|
|
1298
1475
|
});
|
|
1299
1476
|
const updated = await validateDispatchShape(candidate);
|
|
1300
1477
|
const lines = records.map((record, recordIndex) => recordIndex === index
|