taskchef 7.4.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +27 -14
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +40 -14
- package/docs/workflows.md +35 -17
- package/index.js +1 -0
- package/package.json +2 -1
- package/skills/taskchef-report/SKILL.md +8 -6
- package/src/cli.js +20 -0
- package/src/dashboard/app.js +63 -5
- package/src/dashboard/index.html +2 -2
- package/src/dashboard/styles.css +8 -0
- package/src/dashboard.js +30 -1
- package/src/mcp.js +7 -1
- package/src/workspace.js +164 -68
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
TaskChef is a local dispatch desk for Codex. Give one dispatcher a request and
|
|
4
4
|
it records each independently useful outcome, creates a normal Codex task in
|
|
5
5
|
the right project, and returns immediately. The executor task is where live
|
|
6
|
-
work, approvals, and follow-ups happen; TaskChef keeps
|
|
7
|
-
|
|
6
|
+
work, approvals, and follow-ups happen; TaskChef keeps every semantic result
|
|
7
|
+
while projecting the latest one for compact navigation and reporting.
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
request -> recorded TaskChef task -> Codex executor -> current state +
|
|
10
|
+
request -> recorded TaskChef task -> Codex executor -> current state + result history
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Which document should I read?
|
|
@@ -49,7 +49,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
|
|
|
49
49
|
```text
|
|
50
50
|
AGENTS.md managed dispatcher instructions plus user additions
|
|
51
51
|
taskchef.json schema-2 configured projects and routing metadata
|
|
52
|
-
tasks.jsonl one
|
|
52
|
+
tasks.jsonl one task snapshot per line (schema 6; schema 4/5 migration supported)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
List or change routing targets conversationally:
|
|
@@ -119,10 +119,12 @@ when a turn starts and one semantic outcome before that same turn ends:
|
|
|
119
119
|
- `failed`: the executor or creation attempt ended unsuccessfully.
|
|
120
120
|
|
|
121
121
|
A native approval prompt is live Codex state, not `needs_input`.
|
|
122
|
-
TaskChef stores the current reported execution state and
|
|
123
|
-
|
|
124
|
-
immediately without erasing
|
|
125
|
-
|
|
122
|
+
TaskChef stores the current reported execution state and appends every concise
|
|
123
|
+
semantic result to `results`. A follow-up therefore appears as `working`
|
|
124
|
+
immediately without erasing any prior outcome. The final entry is exposed as a
|
|
125
|
+
derived `lastResult` compatibility alias; it is not persisted independently and
|
|
126
|
+
is planned for removal in the next major version after callers move to
|
|
127
|
+
`results.at(-1)`. TaskChef does not store transcripts or non-semantic events.
|
|
126
128
|
|
|
127
129
|
Delegated tasks created by earlier TaskChef versions remain compatible: their
|
|
128
130
|
inline executor protocol still parses, self-links, and may use the deprecated
|
|
@@ -172,7 +174,9 @@ taskchef dashboard --port 3211
|
|
|
172
174
|
```
|
|
173
175
|
|
|
174
176
|
The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
|
|
175
|
-
linked Codex tasks.
|
|
177
|
+
linked Codex tasks. List snapshots and SSE events carry only the latest-result
|
|
178
|
+
projection; opening task details fetches the full newest-first result history.
|
|
179
|
+
It does not mutate TaskChef data and prints its local URL.
|
|
176
180
|
When a compatible foreground dashboard already owns port 3210,
|
|
177
181
|
`ensure_dashboard` reuses it but does not take ownership. If an unknown,
|
|
178
182
|
different-workspace, or stale-version process owns the port, TaskChef reports a
|
|
@@ -183,6 +187,8 @@ The health endpoint contains only a fixed service marker, health schema,
|
|
|
183
187
|
TaskChef version, dashboard-server version, and canonical workspace. It exposes
|
|
184
188
|
no task data, credentials, environment variables, process control, or secrets.
|
|
185
189
|
|
|
190
|
+

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