muse-crew 0.7.1 → 0.7.3
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/API.md +145 -0
- package/lib/crew-api.js +273 -0
- package/lib/schema.sql +62 -0
- package/package.json +1 -1
- package/seed/cron-body-template.md +44 -6
- package/workflows/bugfix.js +1 -0
- package/workflows/chore.js +75 -0
- package/workflows/crew-dispatch.js +57 -0
- package/workflows/standard.js +1 -0
package/API.md
CHANGED
|
@@ -190,6 +190,151 @@ Two namespaces, not one: the vocabulary above is the **dashboard API** (task sta
|
|
|
190
190
|
|
|
191
191
|
Record that a poll tick occurred. Takes no arguments. Used by the dispatcher at the end of each cycle to update the last-polled timestamp.
|
|
192
192
|
|
|
193
|
+
### `reserve-dispatch`
|
|
194
|
+
|
|
195
|
+
Create a dispatch reservation for a task. Called by the dispatcher workflow BEFORE the worker launches the workflow — the reservation is the atomic claim on the task.
|
|
196
|
+
|
|
197
|
+
**Atomic:** Uses `INSERT ... ON CONFLICT DO NOTHING`. Returns `acquired: true` if this call created the reservation, `acquired: false` if one already exists. The dispatcher only recommends tasks where `acquired` is true — fail closed on contention.
|
|
198
|
+
|
|
199
|
+
| Field | Type | Notes |
|
|
200
|
+
|-------|------|-------|
|
|
201
|
+
| `task_id` | string | Required. The task being dispatched. |
|
|
202
|
+
| `run_id` | string | Required. Use `"pending-dispatch"` as the placeholder; the worker updates it with the real platform run ID via `acknowledge-dispatch-run`. |
|
|
203
|
+
| `workflow` | string | Required. Which workflow will be launched (`standard`, `bugfix`, `chore`). |
|
|
204
|
+
| `ttl_seconds` | integer | Optional. Reservation lifetime, clamped to 60–3600 seconds. Default 900 (15 minutes). |
|
|
205
|
+
|
|
206
|
+
Returns `{ ok: true, acquired: <boolean>, task_id, run_id, workflow, dispatched_at, expires_at }`.
|
|
207
|
+
|
|
208
|
+
### `acknowledge-dispatch-run`
|
|
209
|
+
|
|
210
|
+
Update a dispatch reservation with the real platform workflow run ID. Called by the cron worker immediately after `workflow_launch_async` returns.
|
|
211
|
+
|
|
212
|
+
| Field | Type | Notes |
|
|
213
|
+
|-------|------|-------|
|
|
214
|
+
| `task_id` | string | Required. The task that was dispatched. |
|
|
215
|
+
| `run_id` | string | Required. The real platform run ID (e.g. `workflow-run-xxx`). Must not be `"pending-dispatch"`. |
|
|
216
|
+
|
|
217
|
+
Fails closed: throws if no reservation exists for the task (launching without a reservation is forbidden). Returns `{ ok: true, task_id, run_id }`.
|
|
218
|
+
|
|
219
|
+
### `clear-reservation`
|
|
220
|
+
|
|
221
|
+
Remove a task's dispatch reservation. Called by the workflow after a successful self-claim. Takes `task_id` (required). Returns `{ ok: true, cleared: <boolean> }` — `cleared` is false if no reservation existed (idempotent).
|
|
222
|
+
|
|
223
|
+
### `list-reservations`
|
|
224
|
+
|
|
225
|
+
List all active (unexpired) dispatch reservations, ordered by dispatch time. Takes no arguments. The dispatcher consumes this via `getdispatchstate` (which includes `reservations`) rather than calling it directly.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Workflow Telemetry
|
|
230
|
+
|
|
231
|
+
Structured timeline for each launched workflow run. The workflow records its start, milestones, and terminal state. Timestamps come from SQLite `datetime('now')` — workflow JS never reads the wall clock.
|
|
232
|
+
|
|
233
|
+
**Blast radius note (2026-09-13):** Every `agent()` call risks a fatal platform subagent-spawning failure. Telemetry batches non-critical events in memory and flushes them in as few `agent()` calls as possible. Critical events (start, claim, end) flush immediately.
|
|
234
|
+
|
|
235
|
+
### `record-run-start`
|
|
236
|
+
|
|
237
|
+
Record the start of a workflow run. The API mints the `run_id` (UUID, SQLite-side) — the workflow doesn't know its platform run ID.
|
|
238
|
+
|
|
239
|
+
| Field | Type | Notes |
|
|
240
|
+
|-------|------|-------|
|
|
241
|
+
| `task_id` | string | Required. |
|
|
242
|
+
| `workflow` | string | Required. |
|
|
243
|
+
| `launched_by` | string | Optional. Defaults to `"unknown"`. |
|
|
244
|
+
|
|
245
|
+
Returns `{ ok: true, run_id }`.
|
|
246
|
+
|
|
247
|
+
### `record-run-event`
|
|
248
|
+
|
|
249
|
+
Record a single telemetry event. Prefer `record-run-events-batch` for multiple events.
|
|
250
|
+
|
|
251
|
+
| Field | Type | Notes |
|
|
252
|
+
|-------|------|-------|
|
|
253
|
+
| `run_id` | string | Required. |
|
|
254
|
+
| `task_id` | string | Required. |
|
|
255
|
+
| `event_name` | string | Required. |
|
|
256
|
+
| `detail` | string | Optional. |
|
|
257
|
+
|
|
258
|
+
### `record-run-events-batch`
|
|
259
|
+
|
|
260
|
+
Record multiple telemetry events in a single call. Used by the workflow's batched flush.
|
|
261
|
+
|
|
262
|
+
| Field | Type | Notes |
|
|
263
|
+
|-------|------|-------|
|
|
264
|
+
| `run_id` | string | Required. |
|
|
265
|
+
| `task_id` | string | Required. |
|
|
266
|
+
| `events` | array | Required. Each element: `{ event_name: string, detail?: string }`. |
|
|
267
|
+
|
|
268
|
+
Returns `{ ok: true, count }`.
|
|
269
|
+
|
|
270
|
+
### `record-run-end`
|
|
271
|
+
|
|
272
|
+
Record the terminal state of a workflow run.
|
|
273
|
+
|
|
274
|
+
| Field | Type | Notes |
|
|
275
|
+
|-------|------|-------|
|
|
276
|
+
| `run_id` | string | Required. |
|
|
277
|
+
| `status` | string | Required. One of `completed`, `failed`, `parked`, `timed_out`. |
|
|
278
|
+
|
|
279
|
+
### `get-run-timeline`
|
|
280
|
+
|
|
281
|
+
Get a workflow run (or all runs for a task) with its events.
|
|
282
|
+
|
|
283
|
+
| Field | Type | Notes |
|
|
284
|
+
|-------|------|-------|
|
|
285
|
+
| `run_id` | string | Optional. Exactly one of `run_id` or `task_id` is required. |
|
|
286
|
+
| `task_id` | string | Optional. |
|
|
287
|
+
|
|
288
|
+
Returns `{ ok: true, runs: [...], events: [...] }`.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Platform Failure Monitor
|
|
293
|
+
|
|
294
|
+
The platform records workflow run status in `runtime.workflow_runs` — separate from our telemetry. When a workflow dies on a fatal `agent()` failure (e.g. "subagent bootstrap is no longer authorized"), our telemetry shows a run with no events, but the platform knows why. This monitor correlates platform failures with our tasks.
|
|
295
|
+
|
|
296
|
+
### `record-platform-failure`
|
|
297
|
+
|
|
298
|
+
Record a platform workflow run failure. Correlates with a crew telemetry run by timestamp proximity (±60s) if `crew_run_id` is not provided.
|
|
299
|
+
|
|
300
|
+
| Field | Type | Notes |
|
|
301
|
+
|-------|------|-------|
|
|
302
|
+
| `platform_run_id` | string | Required. E.g. `workflow-run-xxx`. |
|
|
303
|
+
| `error_message` | string | Required. The platform error. |
|
|
304
|
+
| `platform_created_at` | string | Required. ISO timestamp of the platform run. |
|
|
305
|
+
| `crew_run_id` | string | Optional. Skips timestamp correlation if provided. |
|
|
306
|
+
| `task_id` | string | Optional. |
|
|
307
|
+
| `workflow` | string | Optional. |
|
|
308
|
+
|
|
309
|
+
Returns `{ ok: true, platform_run_id, crew_run_id, task_id }`.
|
|
310
|
+
|
|
311
|
+
### `get-platform-failures`
|
|
312
|
+
|
|
313
|
+
List recorded platform failures.
|
|
314
|
+
|
|
315
|
+
| Field | Type | Notes |
|
|
316
|
+
|-------|------|-------|
|
|
317
|
+
| `task_id` | string | Optional. Filter by task. |
|
|
318
|
+
| `platform_run_id` | string | Optional. Get a specific failure. |
|
|
319
|
+
| `limit` | integer | Optional. Default 50, max 200. |
|
|
320
|
+
|
|
321
|
+
Returns `{ ok: true, failures: [...] }`.
|
|
322
|
+
|
|
323
|
+
### `retry-platform-failure`
|
|
324
|
+
|
|
325
|
+
Retry a task whose workflow died on a platform failure. Clears the stale reservation, re-queues the task to `todo`, and increments the retry count. If `retry_count` >= `max_retries` (default 3), parks the task instead.
|
|
326
|
+
|
|
327
|
+
| Field | Type | Notes |
|
|
328
|
+
|-------|------|-------|
|
|
329
|
+
| `platform_run_id` | string | Required. |
|
|
330
|
+
| `max_retries` | integer | Optional. Default 3. |
|
|
331
|
+
|
|
332
|
+
Returns `{ ok: true, action: "requeued"|"parked"|"skipped", ... }`.
|
|
333
|
+
|
|
334
|
+
### `mark-failure-retried`
|
|
335
|
+
|
|
336
|
+
Manually increment a failure's retry count without re-queuing. Takes `platform_run_id` (required).
|
|
337
|
+
|
|
193
338
|
---
|
|
194
339
|
|
|
195
340
|
## Projects
|
package/lib/crew-api.js
CHANGED
|
@@ -371,6 +371,15 @@ commands["get-dispatch-state"] = (db) => {
|
|
|
371
371
|
total_sessions: sessionCount,
|
|
372
372
|
total_events: eventCount,
|
|
373
373
|
},
|
|
374
|
+
// Active dispatch reservations: tasks the worker launched but whose
|
|
375
|
+
// workflow has not yet self-claimed. The dispatcher skips these.
|
|
376
|
+
reservations: db.prepare(
|
|
377
|
+
`SELECT task_id, run_id, workflow, dispatched_at, expires_at
|
|
378
|
+
FROM dispatch_reservations WHERE expires_at > ? ORDER BY dispatched_at`
|
|
379
|
+
).all(now()).map((r) => ({
|
|
380
|
+
task_id: r.task_id, run_id: r.run_id, workflow: r.workflow,
|
|
381
|
+
dispatched_at: r.dispatched_at, expires_at: r.expires_at,
|
|
382
|
+
})),
|
|
374
383
|
};
|
|
375
384
|
};
|
|
376
385
|
|
|
@@ -562,6 +571,270 @@ commands["claim-task"] = (db, args) => {
|
|
|
562
571
|
return { ok: true, claimed: false, reason: "already_claimed", existing_session_id: existing.id };
|
|
563
572
|
};
|
|
564
573
|
|
|
574
|
+
// Dispatch reservations: the cron worker creates one after launching a
|
|
575
|
+
// workflow for a task; the dispatcher skips reserved tasks on the next tick;
|
|
576
|
+
// the workflow clears it on self-claim. Expired reservations are inert.
|
|
577
|
+
commands["reserve-dispatch"] = (db, args) => {
|
|
578
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
579
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
580
|
+
if (!args.workflow) throw usageError("workflow is required.");
|
|
581
|
+
requireTask(db, args.task_id);
|
|
582
|
+
const ttl = Math.max(60, Math.min(3600, Number(args.ttl_seconds) || 900));
|
|
583
|
+
const dispatchedAt = now();
|
|
584
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
585
|
+
// ATOMIC: INSERT with ON CONFLICT DO NOTHING. If a reservation already
|
|
586
|
+
// exists (even expired — cleanup is separate), we do NOT overwrite it.
|
|
587
|
+
// The caller must check `acquired` and fail closed if false.
|
|
588
|
+
// This prevents the last-write-wins race where two dispatchers both
|
|
589
|
+
// think they own the task.
|
|
590
|
+
const info = db.prepare(
|
|
591
|
+
`INSERT INTO dispatch_reservations (task_id, run_id, workflow, dispatched_at, expires_at)
|
|
592
|
+
VALUES (?, ?, ?, ?, ?)
|
|
593
|
+
ON CONFLICT(task_id) DO NOTHING`
|
|
594
|
+
).run(args.task_id, args.run_id, args.workflow, dispatchedAt, expiresAt);
|
|
595
|
+
const acquired = info.changes > 0;
|
|
596
|
+
return {
|
|
597
|
+
ok: true,
|
|
598
|
+
acquired,
|
|
599
|
+
task_id: args.task_id,
|
|
600
|
+
run_id: args.run_id,
|
|
601
|
+
workflow: args.workflow,
|
|
602
|
+
dispatched_at: dispatchedAt,
|
|
603
|
+
expires_at: expiresAt,
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
commands["acknowledge-dispatch-run"] = (db, args) => {
|
|
608
|
+
// The worker calls this after workflow_launch_async returns the real
|
|
609
|
+
// platform run_id. Updates the placeholder reservation with the actual
|
|
610
|
+
// run ID. Fails closed if no reservation exists (worker must not have
|
|
611
|
+
// launched without acquiring one).
|
|
612
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
613
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
614
|
+
if (args.run_id === "pending-dispatch") {
|
|
615
|
+
throw usageError("run_id must be the real platform run ID, not the placeholder.");
|
|
616
|
+
}
|
|
617
|
+
const info = db.prepare(
|
|
618
|
+
`UPDATE dispatch_reservations SET run_id = ? WHERE task_id = ?`
|
|
619
|
+
).run(args.run_id, args.task_id);
|
|
620
|
+
if (info.changes === 0) {
|
|
621
|
+
throw usageError("no reservation exists for task_id — launch without reservation is forbidden.");
|
|
622
|
+
}
|
|
623
|
+
return { ok: true, task_id: args.task_id, run_id: args.run_id };
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
commands["clear-reservation"] = (db, args) => {
|
|
627
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
628
|
+
const info = db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(args.task_id);
|
|
629
|
+
return { ok: true, cleared: info.changes > 0 };
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
commands["list-reservations"] = (db) => {
|
|
633
|
+
const rows = db.prepare(
|
|
634
|
+
`SELECT task_id, run_id, workflow, dispatched_at, expires_at
|
|
635
|
+
FROM dispatch_reservations WHERE expires_at > ? ORDER BY dispatched_at`
|
|
636
|
+
).all(now());
|
|
637
|
+
return { ok: true, reservations: rows };
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Workflow run telemetry (2026-09-13): structured timeline for each launched
|
|
641
|
+
// run. The workflow records its start, key milestones (pin, claim, phase
|
|
642
|
+
// boundaries), and its terminal state. Timestamps come from SQLite
|
|
643
|
+
// (datetime('now')) — workflow JS never reads the wall clock.
|
|
644
|
+
commands["record-run-start"] = (db, args) => {
|
|
645
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
646
|
+
if (!args.workflow) throw usageError("workflow is required.");
|
|
647
|
+
// The workflow doesn't know its platform run_id; the telemetry run_id is
|
|
648
|
+
// minted here (SQLite-side) so workflow JS never touches randomness.
|
|
649
|
+
const runId = args.run_id || uuid();
|
|
650
|
+
db.prepare(
|
|
651
|
+
`INSERT INTO workflow_runs (run_id, task_id, workflow, launched_by)
|
|
652
|
+
VALUES (?, ?, ?, ?)
|
|
653
|
+
ON CONFLICT(run_id) DO NOTHING`
|
|
654
|
+
).run(runId, args.task_id, args.workflow, args.launched_by || "unknown");
|
|
655
|
+
return { ok: true, run_id: runId };
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
commands["record-run-event"] = (db, args) => {
|
|
659
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
660
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
661
|
+
if (!args.event_name) throw usageError("event_name is required.");
|
|
662
|
+
db.prepare(
|
|
663
|
+
`INSERT INTO run_events (run_id, task_id, event_name, detail)
|
|
664
|
+
VALUES (?, ?, ?, ?)`
|
|
665
|
+
).run(args.run_id, args.task_id, args.event_name, args.detail || "");
|
|
666
|
+
return { ok: true };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
commands["record-run-events-batch"] = (db, args) => {
|
|
670
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
671
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
672
|
+
if (!Array.isArray(args.events)) throw usageError("events must be an array.");
|
|
673
|
+
const stmt = db.prepare(
|
|
674
|
+
`INSERT INTO run_events (run_id, task_id, event_name, detail)
|
|
675
|
+
VALUES (?, ?, ?, ?)`
|
|
676
|
+
);
|
|
677
|
+
db.exec("BEGIN");
|
|
678
|
+
try {
|
|
679
|
+
for (const e of args.events) {
|
|
680
|
+
if (!e.event_name) continue;
|
|
681
|
+
stmt.run(args.run_id, args.task_id, e.event_name, e.detail || "");
|
|
682
|
+
}
|
|
683
|
+
db.exec("COMMIT");
|
|
684
|
+
} catch (err) {
|
|
685
|
+
db.exec("ROLLBACK");
|
|
686
|
+
throw err;
|
|
687
|
+
}
|
|
688
|
+
return { ok: true, count: args.events.length };
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
commands["record-run-end"] = (db, args) => {
|
|
692
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
693
|
+
const status = args.status || "completed";
|
|
694
|
+
if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
|
|
695
|
+
throw usageError("status must be completed, failed, parked, or timed_out.");
|
|
696
|
+
}
|
|
697
|
+
db.prepare(
|
|
698
|
+
`UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
|
|
699
|
+
WHERE run_id = ?`
|
|
700
|
+
).run(status, args.run_id);
|
|
701
|
+
return { ok: true, run_id: args.run_id, status };
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
commands["get-run-timeline"] = (db, args) => {
|
|
705
|
+
if (!args.run_id && !args.task_id) throw usageError("run_id or task_id is required.");
|
|
706
|
+
let runs;
|
|
707
|
+
if (args.run_id) {
|
|
708
|
+
runs = db.prepare("SELECT * FROM workflow_runs WHERE run_id = ?").all(args.run_id);
|
|
709
|
+
} else {
|
|
710
|
+
runs = db.prepare(
|
|
711
|
+
"SELECT * FROM workflow_runs WHERE task_id = ? ORDER BY launched_at"
|
|
712
|
+
).all(args.task_id);
|
|
713
|
+
}
|
|
714
|
+
const runIds = runs.map((r) => r.run_id);
|
|
715
|
+
let events = [];
|
|
716
|
+
if (runIds.length > 0) {
|
|
717
|
+
const placeholders = runIds.map(() => "?").join(",");
|
|
718
|
+
events = db.prepare(
|
|
719
|
+
`SELECT run_id, event_name, detail, created_at FROM run_events
|
|
720
|
+
WHERE run_id IN (${placeholders}) ORDER BY created_at`
|
|
721
|
+
).all(...runIds);
|
|
722
|
+
}
|
|
723
|
+
return { ok: true, runs, events };
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
commands["record-platform-failure"] = (db, args) => {
|
|
727
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
728
|
+
if (!args.error_message) throw usageError("error_message is required.");
|
|
729
|
+
if (!args.platform_created_at) throw usageError("platform_created_at is required.");
|
|
730
|
+
// Try to correlate with a crew telemetry run by timestamp proximity (±60s).
|
|
731
|
+
// The workflow doesn't know its platform run_id, so we match on launch time.
|
|
732
|
+
let crewRunId = args.crew_run_id || null;
|
|
733
|
+
let taskId = args.task_id || null;
|
|
734
|
+
let workflow = args.workflow || null;
|
|
735
|
+
if (!crewRunId) {
|
|
736
|
+
const match = db.prepare(
|
|
737
|
+
`SELECT run_id, task_id, workflow FROM workflow_runs
|
|
738
|
+
WHERE ABS(strftime('%s', launched_at) - strftime('%s', ?)) < 60
|
|
739
|
+
ORDER BY ABS(strftime('%s', launched_at) - strftime('%s', ?))
|
|
740
|
+
LIMIT 1`
|
|
741
|
+
).get(args.platform_created_at, args.platform_created_at);
|
|
742
|
+
if (match) {
|
|
743
|
+
crewRunId = match.run_id;
|
|
744
|
+
taskId = taskId || match.task_id;
|
|
745
|
+
workflow = workflow || match.workflow;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
db.prepare(
|
|
749
|
+
`INSERT INTO platform_run_failures
|
|
750
|
+
(platform_run_id, crew_run_id, task_id, workflow, error_message, platform_created_at)
|
|
751
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
752
|
+
ON CONFLICT(platform_run_id) DO UPDATE SET
|
|
753
|
+
error_message = excluded.error_message,
|
|
754
|
+
detected_at = datetime('now')`
|
|
755
|
+
).run(args.platform_run_id, crewRunId, taskId, workflow, args.error_message, args.platform_created_at);
|
|
756
|
+
return { ok: true, platform_run_id: args.platform_run_id, crew_run_id: crewRunId, task_id: taskId };
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
commands["get-platform-failures"] = (db, args) => {
|
|
760
|
+
let failures;
|
|
761
|
+
if (args.task_id) {
|
|
762
|
+
failures = db.prepare(
|
|
763
|
+
"SELECT * FROM platform_run_failures WHERE task_id = ? ORDER BY detected_at DESC"
|
|
764
|
+
).all(args.task_id);
|
|
765
|
+
} else if (args.platform_run_id) {
|
|
766
|
+
failures = db.prepare(
|
|
767
|
+
"SELECT * FROM platform_run_failures WHERE platform_run_id = ?"
|
|
768
|
+
).all(args.platform_run_id);
|
|
769
|
+
} else {
|
|
770
|
+
const limit = Math.min(parseInt(args.limit) || 50, 200);
|
|
771
|
+
failures = db.prepare(
|
|
772
|
+
"SELECT * FROM platform_run_failures ORDER BY detected_at DESC LIMIT ?"
|
|
773
|
+
).all(limit);
|
|
774
|
+
}
|
|
775
|
+
return { ok: true, failures };
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
commands["mark-failure-retried"] = (db, args) => {
|
|
779
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
780
|
+
db.prepare(
|
|
781
|
+
`UPDATE platform_run_failures
|
|
782
|
+
SET retry_count = retry_count + 1, last_retry_at = datetime('now')
|
|
783
|
+
WHERE platform_run_id = ?`
|
|
784
|
+
).run(args.platform_run_id);
|
|
785
|
+
return { ok: true, platform_run_id: args.platform_run_id };
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
commands["retry-platform-failure"] = (db, args) => {
|
|
789
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
790
|
+
const maxRetries = parseInt(args.max_retries) || 3;
|
|
791
|
+
const failure = db.prepare(
|
|
792
|
+
"SELECT * FROM platform_run_failures WHERE platform_run_id = ?"
|
|
793
|
+
).get(args.platform_run_id);
|
|
794
|
+
if (!failure) throw usageError("platform_run_id not found.");
|
|
795
|
+
if (!failure.task_id) {
|
|
796
|
+
return { ok: true, action: "skipped", reason: "no task correlated" };
|
|
797
|
+
}
|
|
798
|
+
if (failure.retry_count >= maxRetries) {
|
|
799
|
+
// Park the task — transient failures are not resolving.
|
|
800
|
+
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(failure.task_id);
|
|
801
|
+
if (task && task.state !== "parked") {
|
|
802
|
+
const msg = "Platform workflow failed " + (failure.retry_count + 1) + "x: " +
|
|
803
|
+
failure.error_message.substring(0, 200);
|
|
804
|
+
db.prepare("UPDATE tasks SET state = 'parked', updated_at = datetime('now') WHERE id = ?")
|
|
805
|
+
.run(failure.task_id);
|
|
806
|
+
db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
|
|
807
|
+
db.prepare(
|
|
808
|
+
`INSERT INTO events (task_id, event_type, message) VALUES (?, 'parked', ?)`
|
|
809
|
+
).run(failure.task_id, msg);
|
|
810
|
+
}
|
|
811
|
+
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count };
|
|
812
|
+
}
|
|
813
|
+
// Clear reservation and re-queue for retry.
|
|
814
|
+
db.exec("BEGIN");
|
|
815
|
+
try {
|
|
816
|
+
db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
|
|
817
|
+
db.prepare(
|
|
818
|
+
"UPDATE tasks SET state = 'todo', updated_at = datetime('now') WHERE id = ? AND state != 'done'"
|
|
819
|
+
).run(failure.task_id);
|
|
820
|
+
db.prepare(
|
|
821
|
+
`UPDATE platform_run_failures
|
|
822
|
+
SET retry_count = retry_count + 1, last_retry_at = datetime('now')
|
|
823
|
+
WHERE platform_run_id = ?`
|
|
824
|
+
).run(args.platform_run_id);
|
|
825
|
+
db.exec("COMMIT");
|
|
826
|
+
} catch (e) {
|
|
827
|
+
db.exec("ROLLBACK");
|
|
828
|
+
throw e;
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
action: "requeued",
|
|
833
|
+
task_id: failure.task_id,
|
|
834
|
+
retry_count: failure.retry_count + 1,
|
|
835
|
+
};
|
|
836
|
+
};
|
|
837
|
+
|
|
565
838
|
commands["park-task"] = (db, args) => {
|
|
566
839
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
567
840
|
const message = (args.message ?? "").trim();
|
package/lib/schema.sql
CHANGED
|
@@ -99,3 +99,65 @@ CREATE INDEX IF NOT EXISTS events_task_id_idx ON events(task_id);
|
|
|
99
99
|
-- another session holds the claim.
|
|
100
100
|
CREATE UNIQUE INDEX IF NOT EXISTS agent_sessions_one_running_per_task_idx
|
|
101
101
|
ON agent_sessions(task_id) WHERE status = 'running';
|
|
102
|
+
|
|
103
|
+
-- Dispatch reservations: the dispatcher recommends, the cron worker launches,
|
|
104
|
+
-- and the launched workflow self-claims. The claim can take 15+ minutes for
|
|
105
|
+
-- cron-launched runs (canary 2026-09-13), far longer than the 3-minute poll
|
|
106
|
+
-- interval — without a reservation, every tick re-dispatches the same task.
|
|
107
|
+
-- The worker creates a reservation after launching; the dispatcher skips
|
|
108
|
+
-- reserved tasks; the workflow clears it on self-claim. Expired reservations
|
|
109
|
+
-- are ignored (the workflow died before claiming).
|
|
110
|
+
CREATE TABLE IF NOT EXISTS dispatch_reservations (
|
|
111
|
+
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
|
112
|
+
run_id TEXT NOT NULL,
|
|
113
|
+
workflow TEXT NOT NULL,
|
|
114
|
+
dispatched_at TEXT NOT NULL,
|
|
115
|
+
expires_at TEXT NOT NULL
|
|
116
|
+
);
|
|
117
|
+
CREATE INDEX IF NOT EXISTS dispatch_reservations_expires_idx
|
|
118
|
+
ON dispatch_reservations(expires_at);
|
|
119
|
+
|
|
120
|
+
-- Workflow run telemetry (2026-09-13): the crew had no visibility into what
|
|
121
|
+
-- a launched workflow was doing — a task could sit in_progress for hours
|
|
122
|
+
-- with only a timed_out session as evidence. These tables give every run
|
|
123
|
+
-- a structured timeline: when it started, what phases it entered/exited,
|
|
124
|
+
-- when it claimed the task, and how it ended. Timestamps are written by
|
|
125
|
+
-- SQLite (datetime('now')), never by workflow JS — the determinism guard
|
|
126
|
+
-- forbids wall-clock in workflow scripts.
|
|
127
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
128
|
+
run_id TEXT PRIMARY KEY,
|
|
129
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
130
|
+
workflow TEXT NOT NULL,
|
|
131
|
+
launched_by TEXT NOT NULL DEFAULT 'unknown',
|
|
132
|
+
launched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
133
|
+
completed_at TEXT,
|
|
134
|
+
status TEXT NOT NULL DEFAULT 'running'
|
|
135
|
+
CHECK (status IN ('running', 'completed', 'failed', 'parked', 'timed_out'))
|
|
136
|
+
);
|
|
137
|
+
CREATE TABLE IF NOT EXISTS run_events (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
run_id TEXT NOT NULL REFERENCES workflow_runs(run_id) ON DELETE CASCADE,
|
|
140
|
+
task_id TEXT NOT NULL,
|
|
141
|
+
event_name TEXT NOT NULL,
|
|
142
|
+
detail TEXT NOT NULL DEFAULT '',
|
|
143
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
144
|
+
);
|
|
145
|
+
CREATE INDEX IF NOT EXISTS workflow_runs_task_id_idx ON workflow_runs(task_id);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS run_events_run_id_idx ON run_events(run_id);
|
|
147
|
+
|
|
148
|
+
-- Platform workflow run failures (from runtime.workflow_runs).
|
|
149
|
+
-- The platform records workflow run status separately from our telemetry.
|
|
150
|
+
-- This table correlates platform failures with our tasks for visibility.
|
|
151
|
+
CREATE TABLE IF NOT EXISTS platform_run_failures (
|
|
152
|
+
platform_run_id TEXT PRIMARY KEY,
|
|
153
|
+
crew_run_id TEXT REFERENCES workflow_runs(run_id) ON DELETE SET NULL,
|
|
154
|
+
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
155
|
+
workflow TEXT,
|
|
156
|
+
error_message TEXT NOT NULL,
|
|
157
|
+
platform_created_at TEXT NOT NULL,
|
|
158
|
+
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
159
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
160
|
+
last_retry_at TEXT
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS platform_run_failures_task_id_idx ON platform_run_failures(task_id);
|
|
163
|
+
CREATE INDEX IF NOT EXISTS platform_run_failures_detected_at_idx ON platform_run_failures(detected_at);
|
package/package.json
CHANGED
|
@@ -4,6 +4,21 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
|
+
0. **Check for platform workflow failures (opacity killer):** The platform records workflow run failures in `runtime.workflow_runs` — these are invisible in the crew DB unless you check. A workflow that dies on a fatal `agent()` error (e.g. "subagent bootstrap is no longer authorized") leaves its task stranded with no explanation.
|
|
8
|
+
- Use `muse.db` to find failed platform runs in the last 15 minutes:
|
|
9
|
+
```sql
|
|
10
|
+
SELECT w.run_id, w.created_at, c.error
|
|
11
|
+
FROM runtime.workflow_runs w
|
|
12
|
+
LEFT JOIN runtime.workflow_agent_calls c ON c.run_id = w.run_id AND c.status = 'failed'
|
|
13
|
+
WHERE w.created_at > now() - interval '15 minutes' AND w.status = 'failed'
|
|
14
|
+
ORDER BY w.created_at DESC
|
|
15
|
+
```
|
|
16
|
+
- For each failed run, record it in the crew DB (correlates by timestamp with our telemetry):
|
|
17
|
+
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-platform-failure --json '{"platform_run_id": "<run_id>", "error_message": "<error>", "platform_created_at": "<created_at>"}'`
|
|
18
|
+
- Then retry each failure (clears stale reservation, re-queues task, or parks after 3 attempts):
|
|
19
|
+
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} retry-platform-failure --json '{"platform_run_id": "<run_id>"}'`
|
|
20
|
+
- Log the results. This ensures transient platform failures don't strand tasks.
|
|
21
|
+
|
|
7
22
|
1. **Load tools:** Call tool_search_load_tool_namespace with paths ["workflow_launch"].
|
|
8
23
|
|
|
9
24
|
2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and parse it as JSON. If the file does not exist (the live release predates the registry), proceed without it — omit the `registry` arg and the dispatcher will load the registry the slow way and log a warning.
|
|
@@ -13,9 +28,32 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
13
28
|
Wait for it to complete. It reads the crew's task state, determines eligibility, claims tasks, acknowledges the poll, and returns structured results.
|
|
14
29
|
|
|
15
30
|
4. **Launch claims autonomously:** Extract the `claims` array from the dispatcher result. For each claim (up to 3 per tick — if more than 3, launch the first 3 and log the rest as deferred):
|
|
16
|
-
- Call workflow_launch_async with scriptPath={claim.scriptPath} and args={claim.args}.
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
If the
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
- Call workflow_launch_async with scriptPath={claim.scriptPath} and args={claim.args}. Record the returned run_id.
|
|
32
|
+
- Immediately acknowledge the dispatch reservation with the real run_id (the dispatcher already created a placeholder reservation — this updates it). Run in shell:
|
|
33
|
+
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} acknowledge-dispatch-run --json '{"task_id": "{claim.task_id}", "run_id": "<run_id>"}'`
|
|
34
|
+
- If the acknowledge fails (no reservation exists), DO NOT LAUNCH — the dispatcher did not acquire this task. This is a safety invariant.
|
|
35
|
+
- The launched workflow self-claims the task and clears the reservation as its first actions. If the task was already claimed or is done, the claim fails closed and the run stands down quietly — this is the mechanical duplicate protection, not an error.
|
|
36
|
+
|
|
37
|
+
If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH and exit.
|
|
38
|
+
|
|
39
|
+
5. **Monitor launched workflows (launcher stays alive — platform workaround):** The platform ties async workflow subagent authorization to the launcher's lifetime. If you exit now, the workflow's next `agent()` call may fail with "subagent bootstrap is no longer authorized." You are the monitor — stay alive until each workflow reaches a terminal state.
|
|
40
|
+
- For each launched run_id, poll its status every 2 minutes via muse.db:
|
|
41
|
+
```sql
|
|
42
|
+
SELECT status, error FROM runtime.workflow_runs WHERE run_id = '<run_id>'
|
|
43
|
+
```
|
|
44
|
+
- **If status is `completed`:** Done. Log success and stop monitoring this run.
|
|
45
|
+
- **If status is `failed`:** Check if it's a platform `agent()` error (error contains "subagent bootstrap", "reservation owner is terminal", "bootstrap was cancelled", or "workflow agent call failed"):
|
|
46
|
+
- **Platform error:** Record it and retry:
|
|
47
|
+
1. `node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-platform-failure --json '{"platform_run_id": "<run_id>", "error_message": "<error>"}'`
|
|
48
|
+
2. `node {crewHome}/lib/crew-api.js --crew-home {crewHome} retry-platform-failure --json '{"platform_run_id": "<run_id>"}'`
|
|
49
|
+
3. If the retry result says `requeued` and you have retries remaining (max 3 per task per tick):
|
|
50
|
+
- Re-acquire the reservation: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} reserve-dispatch --json '{"task_id": "<task_id>"}'`
|
|
51
|
+
- If `acquired` is true, re-launch via workflow_launch_async with the same scriptPath and args, acknowledge with the new run_id, and continue monitoring the NEW run_id.
|
|
52
|
+
- If `acquired` is false, stop — another dispatcher claimed it.
|
|
53
|
+
4. If the retry result says `parked` (3 attempts exhausted), stop monitoring this task.
|
|
54
|
+
- **Task-level error (not a platform error):** The workflow's own error handling applies. Stop monitoring this run.
|
|
55
|
+
- **If status is `running` or `paused`:** Continue polling.
|
|
56
|
+
- **Timeout:** If a workflow hasn't reached terminal state after 90 minutes, log it and stop monitoring (the next tick's Step 0 will catch it if it failed). The monitor timeout MUST exceed the longest workflow `agent()` timeout (currently 60 minutes for the work phase) — otherwise the launcher shuts down mid-workflow and the bug recurs.
|
|
57
|
+
- When all launched workflows are terminal or retry-exhausted, exit silently.
|
|
58
|
+
|
|
59
|
+
6. Exit silently.
|
package/workflows/bugfix.js
CHANGED
|
@@ -1060,6 +1060,7 @@ while (i < STEPS.length) {
|
|
|
1060
1060
|
"Claim this task for the " + step.name + " step.\n" +
|
|
1061
1061
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1062
1062
|
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
|
|
1063
|
+
"If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
|
|
1063
1064
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
1064
1065
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
1065
1066
|
{
|
package/workflows/chore.js
CHANGED
|
@@ -50,6 +50,66 @@ function crewCmd(command, args) {
|
|
|
50
50
|
var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
|
|
51
51
|
return "node " + CREW_API + " --crew-home " + crewHome + " " + command + " --json '" + json + "'";
|
|
52
52
|
}
|
|
53
|
+
// Telemetry (2026-09-13): structured run timeline. The workflow mints a
|
|
54
|
+
// telemetry run_id at startup via record-run-start (the API generates it
|
|
55
|
+
// server-side — workflow JS never touches the clock or randomness) and
|
|
56
|
+
// records milestone events. Timestamps come from SQLite datetime('now').
|
|
57
|
+
// Fire-and-forget: telemetry must never break the run (no schema — the
|
|
58
|
+
// run-5/run-6 fire-and-forget rule).
|
|
59
|
+
//
|
|
60
|
+
// BLAST RADIUS REDUCTION (2026-09-13): Every agent() call is a chance for
|
|
61
|
+
// the platform to kill the workflow with a subagent-spawning failure
|
|
62
|
+
// ("bootstrap is no longer authorized", "reservation owner is terminal").
|
|
63
|
+
// These failures are FATAL to the workflow — try/catch does not save us.
|
|
64
|
+
// So we batch non-critical telemetry events in memory and flush them in
|
|
65
|
+
// as few agent() calls as possible. Critical events (start, claim,
|
|
66
|
+
// completion, park) flush immediately.
|
|
67
|
+
let telemetryRunId = null;
|
|
68
|
+
let telemetryBuffer = [];
|
|
69
|
+
async function telemetryStart(workflowName) {
|
|
70
|
+
try {
|
|
71
|
+
const out = await agent(crewCmd("record-run-start", {
|
|
72
|
+
task_id: taskId, workflow: workflowName, launched_by: "cron"
|
|
73
|
+
}), { key: "telemetry-start", label: "Recording run start" });
|
|
74
|
+
const parsed = typeof out === "string" ? JSON.parse(out) : out;
|
|
75
|
+
if (parsed && parsed.run_id) telemetryRunId = parsed.run_id;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
log("Telemetry start failed (non-fatal): " + (e && e.message ? e.message : e));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function telemetryEvent(eventName, detail) {
|
|
81
|
+
// Buffer non-critical events; flush at critical points.
|
|
82
|
+
if (!telemetryRunId) return;
|
|
83
|
+
telemetryBuffer.push({ event_name: eventName, detail: detail || "" });
|
|
84
|
+
// Auto-flush if buffer gets large (avoid unbounded memory).
|
|
85
|
+
if (telemetryBuffer.length >= 10) {
|
|
86
|
+
await telemetryFlush();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function telemetryFlush() {
|
|
90
|
+
if (!telemetryRunId || telemetryBuffer.length === 0) return;
|
|
91
|
+
const events = telemetryBuffer.splice(0, telemetryBuffer.length);
|
|
92
|
+
try {
|
|
93
|
+
// Send all buffered events in a single agent() call via a batch command.
|
|
94
|
+
await agent(crewCmd("record-run-events-batch", {
|
|
95
|
+
run_id: telemetryRunId, task_id: taskId, events: events
|
|
96
|
+
}), { key: "telemetry-flush", label: "Flushing " + events.length + " telemetry events" });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
log("Telemetry flush failed (non-fatal): " + events.length + " events lost");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function telemetryEnd(status) {
|
|
102
|
+
if (!telemetryRunId) return;
|
|
103
|
+
// Flush any buffered events before recording the end.
|
|
104
|
+
await telemetryFlush();
|
|
105
|
+
try {
|
|
106
|
+
await agent(crewCmd("record-run-end", {
|
|
107
|
+
run_id: telemetryRunId, status: status
|
|
108
|
+
}), { key: "telemetry-end", label: "Recording run end" });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log("Telemetry end failed (non-fatal)");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
53
113
|
const ORCH_PATH = crewHome + "/.orchestration";
|
|
54
114
|
// Pin lifecycle scripts to this run
|
|
55
115
|
const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
|
|
@@ -846,16 +906,23 @@ async function parkTask(reason) {
|
|
|
846
906
|
return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
|
|
847
907
|
}
|
|
848
908
|
await terminalCleanup();
|
|
909
|
+
await telemetryEnd("parked");
|
|
849
910
|
return { status: "parked", task_id: taskId, reason: reason };
|
|
850
911
|
}
|
|
851
912
|
let i = startStepIndex;
|
|
852
913
|
|
|
914
|
+
// Telemetry: mark run start before anything else (pin timing is the
|
|
915
|
+
// 2026-09-13 mystery — the pin agent took 15m with no visibility).
|
|
916
|
+
await telemetryStart("chore");
|
|
917
|
+
|
|
853
918
|
// ── Pin lifecycle scripts ────────────────────────────────────────────
|
|
854
919
|
// Copy lifecycle scripts into a per-task temp dir so this run is immune
|
|
855
920
|
// to upgrades that land while it's in flight. Verified mechanically:
|
|
856
921
|
// workflow code asserts the four basenames from the verbatim listing —
|
|
857
922
|
// the agent cannot self-certify. Any miss parks the task before Triage.
|
|
923
|
+
await telemetryEvent("pin_start");
|
|
858
924
|
const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
|
|
925
|
+
await telemetryEvent("pin_end", "pinned " + initialPins.length + " files");
|
|
859
926
|
const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
|
|
860
927
|
if (missingInitialPins.length > 0) {
|
|
861
928
|
return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
|
|
@@ -874,6 +941,7 @@ while (i < STEPS.length) {
|
|
|
874
941
|
|
|
875
942
|
phase(step.name);
|
|
876
943
|
log(step.name + " step (" + step.identity + ") for task " + taskId);
|
|
944
|
+
await telemetryEvent("phase_start", step.name);
|
|
877
945
|
|
|
878
946
|
// ── Project-change guard ───────────────────────────────────────────
|
|
879
947
|
// A task moved to another project mid-run must not keep working in the
|
|
@@ -1017,6 +1085,7 @@ while (i < STEPS.length) {
|
|
|
1017
1085
|
"Claim this task for the " + step.name + " step.\n" +
|
|
1018
1086
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1019
1087
|
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
|
|
1088
|
+
"If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
|
|
1020
1089
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
1021
1090
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
1022
1091
|
{
|
|
@@ -1031,9 +1100,14 @@ while (i < STEPS.length) {
|
|
|
1031
1100
|
);
|
|
1032
1101
|
if (!claimResult.claimed) {
|
|
1033
1102
|
log("Standing down — task " + taskId + " was already claimed by another run");
|
|
1103
|
+
await telemetryEnd("duplicate");
|
|
1034
1104
|
return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
|
|
1035
1105
|
}
|
|
1036
1106
|
activeSessionId = claimResult.session_id;
|
|
1107
|
+
await telemetryEvent("claim", step.name + " claimed");
|
|
1108
|
+
// Flush telemetry before proceeding — the claim is a critical point.
|
|
1109
|
+
// If a subsequent agent() fails, we want the claim event persisted.
|
|
1110
|
+
await telemetryFlush();
|
|
1037
1111
|
} else {
|
|
1038
1112
|
const claimResult = await agent(
|
|
1039
1113
|
"Claim a session for this task step.\n" +
|
|
@@ -2212,5 +2286,6 @@ await agent(
|
|
|
2212
2286
|
);
|
|
2213
2287
|
|
|
2214
2288
|
log("Chore workflow complete for task " + taskId);
|
|
2289
|
+
await telemetryEnd("completed");
|
|
2215
2290
|
await agent("Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB, { key: "cleanup-pins", label: "Cleaning pinned scripts" });
|
|
2216
2291
|
return { status: "ok", task_id: taskId, message: "Chore workflow complete for " + taskTitle };
|
|
@@ -237,6 +237,15 @@ const allTasks = boardData.ready_tasks.map(projectTaskRecord).filter(function (t
|
|
|
237
237
|
const config = boardData.config || {};
|
|
238
238
|
const projects = boardData.projects || [];
|
|
239
239
|
|
|
240
|
+
// Dispatch reservations: tasks the worker launched on a previous tick whose
|
|
241
|
+
// workflow has not yet self-claimed. The claim can take 15+ minutes for
|
|
242
|
+
// cron-launched runs (canary 2026-09-13) — far longer than the poll interval —
|
|
243
|
+
// so without this filter every tick would re-dispatch the same task.
|
|
244
|
+
const reservedTaskIds = new Set((boardData.reservations || []).map(function (r) { return r.task_id; }));
|
|
245
|
+
if (reservedTaskIds.size > 0) {
|
|
246
|
+
log("Reservations: skipping " + reservedTaskIds.size + " task(s) with an active dispatch reservation");
|
|
247
|
+
}
|
|
248
|
+
|
|
240
249
|
// Default project: explicit arg, or first registered project
|
|
241
250
|
const DEFAULT_PROJECT = inputs.defaultProject || (projects.length > 0 ? projects[0].id : "");
|
|
242
251
|
|
|
@@ -383,6 +392,14 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
383
392
|
var task = allTasks[t];
|
|
384
393
|
if (task.blocked) continue;
|
|
385
394
|
|
|
395
|
+
// Skip tasks with an active dispatch reservation: the worker launched a
|
|
396
|
+
// workflow for this task on a previous tick, but the workflow has not yet
|
|
397
|
+
// self-claimed (claims can take 15+ minutes for cron-launched runs).
|
|
398
|
+
if (reservedTaskIds.has(task.id)) {
|
|
399
|
+
log("Skipped \"" + task.title + "\" — active dispatch reservation (workflow launched, claim pending)");
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
386
403
|
// Skip tasks from quiesced projects
|
|
387
404
|
var taskProject = task.project || DEFAULT_PROJECT;
|
|
388
405
|
if (quiescedProjects[taskProject]) {
|
|
@@ -790,6 +807,46 @@ results.forEach(function(r) {
|
|
|
790
807
|
recommended.push(r);
|
|
791
808
|
});
|
|
792
809
|
var completed = results.filter(function(r) { return r.action === "completed"; });
|
|
810
|
+
|
|
811
|
+
// Atomic reservation: create a dispatch reservation for each recommended task
|
|
812
|
+
// BEFORE returning. This closes the launch→reservation race — the worker no
|
|
813
|
+
// longer needs to create reservations (it updates the run_id after launch).
|
|
814
|
+
// If the worker dies or times out, the reservation expires via TTL.
|
|
815
|
+
// Note: run_id uses a deterministic placeholder (no wall-clock — see
|
|
816
|
+
// tests/determinism.test.js); the worker updates it with the real run_id
|
|
817
|
+
// via acknowledge-dispatch-run after workflow_launch_async returns.
|
|
818
|
+
if (recommended.length > 0) {
|
|
819
|
+
var acquired = [];
|
|
820
|
+
for (var i = 0; i < recommended.length; i++) {
|
|
821
|
+
var rec = recommended[i];
|
|
822
|
+
var reserveCmd = crewCmd("reserve-dispatch", {
|
|
823
|
+
task_id: rec.task_id,
|
|
824
|
+
run_id: "pending-dispatch",
|
|
825
|
+
workflow: rec.workflow
|
|
826
|
+
});
|
|
827
|
+
try {
|
|
828
|
+
var reserveOut = await agent(
|
|
829
|
+
"Create a dispatch reservation.\nRun in shell and return the stdout verbatim:\n" + reserveCmd,
|
|
830
|
+
{ key: "reserve-" + rec.task_id.slice(0, 8), label: "Reserving dispatch for " + rec.task_id.slice(0, 8) }
|
|
831
|
+
);
|
|
832
|
+
var reserveParsed = typeof reserveOut === "string" ? JSON.parse(reserveOut) : reserveOut;
|
|
833
|
+
if (reserveParsed && reserveParsed.acquired) {
|
|
834
|
+
log("Reserved task " + rec.task_id.slice(0, 8));
|
|
835
|
+
acquired.push(rec);
|
|
836
|
+
} else {
|
|
837
|
+
// FAIL CLOSED: Another dispatcher owns this task. Do not recommend it.
|
|
838
|
+
log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation not acquired (another dispatcher owns it)");
|
|
839
|
+
}
|
|
840
|
+
} catch (e) {
|
|
841
|
+
// FAIL CLOSED: If we cannot reserve, we cannot safely recommend.
|
|
842
|
+
// The task stays eligible for the next poll.
|
|
843
|
+
log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation failed: " + e.message);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Only recommend tasks we actually acquired.
|
|
847
|
+
recommended = acquired;
|
|
848
|
+
}
|
|
849
|
+
|
|
793
850
|
var msg = "Dispatch complete.";
|
|
794
851
|
if (recommended.length > 0) {
|
|
795
852
|
msg += " Recommended: " + recommended.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
|
package/workflows/standard.js
CHANGED
|
@@ -1059,6 +1059,7 @@ while (i < STEPS.length) {
|
|
|
1059
1059
|
"Claim this task for the " + step.name + " step.\n" +
|
|
1060
1060
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1061
1061
|
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
|
|
1062
|
+
"If the claim response has claimed=true, then run in shell and return the stdout verbatim:\n" + crewCmd("clear-reservation", { task_id: taskId }) + "\n" +
|
|
1062
1063
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
1063
1064
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
1064
1065
|
{
|