muse-crew 0.7.2 → 0.7.4
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 +129 -5
- package/lib/crew-api.js +242 -5
- package/lib/schema.sql +45 -0
- package/package.json +1 -1
- package/seed/cron-body-template.md +41 -5
- package/workflows/chore.js +74 -0
- package/workflows/crew-dispatch.js +90 -0
package/API.md
CHANGED
|
@@ -192,16 +192,29 @@ Record that a poll tick occurred. Takes no arguments. Used by the dispatcher at
|
|
|
192
192
|
|
|
193
193
|
### `reserve-dispatch`
|
|
194
194
|
|
|
195
|
-
Create a dispatch reservation for a task
|
|
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 UPDATE` with a reclaim guard. Returns `acquired: true` if this call created the reservation OR reclaimed an expired one (the existing row is overwritten only when its `expires_at` is past — no separate reaper, no window where a dead row blocks redispatch). Returns `acquired: false` if a live reservation already exists — the competing dispatcher fails closed. Reclaiming is safe: workflows clear their reservation on self-claim, so an expired row always means the launch died before claiming. The dispatcher only recommends tasks where `acquired` is true — fail closed on contention.
|
|
196
198
|
|
|
197
199
|
| Field | Type | Notes |
|
|
198
200
|
|-------|------|-------|
|
|
199
|
-
| `task_id` | string | Required. The task
|
|
200
|
-
| `run_id` | string | Required.
|
|
201
|
-
| `workflow` | string | Required. Which workflow
|
|
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`). |
|
|
202
204
|
| `ttl_seconds` | integer | Optional. Reservation lifetime, clamped to 60–3600 seconds. Default 900 (15 minutes). |
|
|
203
205
|
|
|
204
|
-
|
|
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 }`.
|
|
205
218
|
|
|
206
219
|
### `clear-reservation`
|
|
207
220
|
|
|
@@ -213,6 +226,117 @@ List all active (unexpired) dispatch reservations, ordered by dispatch time. Tak
|
|
|
213
226
|
|
|
214
227
|
---
|
|
215
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
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
216
340
|
## Projects
|
|
217
341
|
|
|
218
342
|
### `createproject`
|
package/lib/crew-api.js
CHANGED
|
@@ -582,14 +582,53 @@ commands["reserve-dispatch"] = (db, args) => {
|
|
|
582
582
|
const ttl = Math.max(60, Math.min(3600, Number(args.ttl_seconds) || 900));
|
|
583
583
|
const dispatchedAt = now();
|
|
584
584
|
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
585
|
-
|
|
585
|
+
// ATOMIC RECLAIM (2026-09-13): an expired reservation row is reclaimed by
|
|
586
|
+
// overwriting it inside the same UPSERT — no separate reaper, no window
|
|
587
|
+
// where a dead row blocks redispatch. A LIVE reservation still wins the
|
|
588
|
+
// race: the WHERE clause rejects the overwrite and `acquired` stays false,
|
|
589
|
+
// so the caller fails closed exactly as before.
|
|
590
|
+
// Safe to reclaim: workflows clear their reservation on self-claim, so an
|
|
591
|
+
// expired row always means the launch died before claiming — reclaiming it
|
|
592
|
+
// can never duplicate a running workflow.
|
|
593
|
+
const info = db.prepare(
|
|
586
594
|
`INSERT INTO dispatch_reservations (task_id, run_id, workflow, dispatched_at, expires_at)
|
|
587
595
|
VALUES (?, ?, ?, ?, ?)
|
|
588
596
|
ON CONFLICT(task_id) DO UPDATE SET
|
|
589
|
-
run_id = excluded.run_id,
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
597
|
+
run_id = excluded.run_id,
|
|
598
|
+
workflow = excluded.workflow,
|
|
599
|
+
dispatched_at = excluded.dispatched_at,
|
|
600
|
+
expires_at = excluded.expires_at
|
|
601
|
+
WHERE dispatch_reservations.expires_at <= ?`
|
|
602
|
+
).run(args.task_id, args.run_id, args.workflow, dispatchedAt, expiresAt, now());
|
|
603
|
+
const acquired = info.changes > 0;
|
|
604
|
+
return {
|
|
605
|
+
ok: true,
|
|
606
|
+
acquired,
|
|
607
|
+
task_id: args.task_id,
|
|
608
|
+
run_id: args.run_id,
|
|
609
|
+
workflow: args.workflow,
|
|
610
|
+
dispatched_at: dispatchedAt,
|
|
611
|
+
expires_at: expiresAt,
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
commands["acknowledge-dispatch-run"] = (db, args) => {
|
|
616
|
+
// The worker calls this after workflow_launch_async returns the real
|
|
617
|
+
// platform run_id. Updates the placeholder reservation with the actual
|
|
618
|
+
// run ID. Fails closed if no reservation exists (worker must not have
|
|
619
|
+
// launched without acquiring one).
|
|
620
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
621
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
622
|
+
if (args.run_id === "pending-dispatch") {
|
|
623
|
+
throw usageError("run_id must be the real platform run ID, not the placeholder.");
|
|
624
|
+
}
|
|
625
|
+
const info = db.prepare(
|
|
626
|
+
`UPDATE dispatch_reservations SET run_id = ? WHERE task_id = ?`
|
|
627
|
+
).run(args.run_id, args.task_id);
|
|
628
|
+
if (info.changes === 0) {
|
|
629
|
+
throw usageError("no reservation exists for task_id — launch without reservation is forbidden.");
|
|
630
|
+
}
|
|
631
|
+
return { ok: true, task_id: args.task_id, run_id: args.run_id };
|
|
593
632
|
};
|
|
594
633
|
|
|
595
634
|
commands["clear-reservation"] = (db, args) => {
|
|
@@ -606,6 +645,204 @@ commands["list-reservations"] = (db) => {
|
|
|
606
645
|
return { ok: true, reservations: rows };
|
|
607
646
|
};
|
|
608
647
|
|
|
648
|
+
// Workflow run telemetry (2026-09-13): structured timeline for each launched
|
|
649
|
+
// run. The workflow records its start, key milestones (pin, claim, phase
|
|
650
|
+
// boundaries), and its terminal state. Timestamps come from SQLite
|
|
651
|
+
// (datetime('now')) — workflow JS never reads the wall clock.
|
|
652
|
+
commands["record-run-start"] = (db, args) => {
|
|
653
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
654
|
+
if (!args.workflow) throw usageError("workflow is required.");
|
|
655
|
+
// The workflow doesn't know its platform run_id; the telemetry run_id is
|
|
656
|
+
// minted here (SQLite-side) so workflow JS never touches randomness.
|
|
657
|
+
const runId = args.run_id || uuid();
|
|
658
|
+
db.prepare(
|
|
659
|
+
`INSERT INTO workflow_runs (run_id, task_id, workflow, launched_by)
|
|
660
|
+
VALUES (?, ?, ?, ?)
|
|
661
|
+
ON CONFLICT(run_id) DO NOTHING`
|
|
662
|
+
).run(runId, args.task_id, args.workflow, args.launched_by || "unknown");
|
|
663
|
+
return { ok: true, run_id: runId };
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
commands["record-run-event"] = (db, args) => {
|
|
667
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
668
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
669
|
+
if (!args.event_name) throw usageError("event_name is required.");
|
|
670
|
+
db.prepare(
|
|
671
|
+
`INSERT INTO run_events (run_id, task_id, event_name, detail)
|
|
672
|
+
VALUES (?, ?, ?, ?)`
|
|
673
|
+
).run(args.run_id, args.task_id, args.event_name, args.detail || "");
|
|
674
|
+
return { ok: true };
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
commands["record-run-events-batch"] = (db, args) => {
|
|
678
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
679
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
680
|
+
if (!Array.isArray(args.events)) throw usageError("events must be an array.");
|
|
681
|
+
const stmt = db.prepare(
|
|
682
|
+
`INSERT INTO run_events (run_id, task_id, event_name, detail)
|
|
683
|
+
VALUES (?, ?, ?, ?)`
|
|
684
|
+
);
|
|
685
|
+
db.exec("BEGIN");
|
|
686
|
+
try {
|
|
687
|
+
for (const e of args.events) {
|
|
688
|
+
if (!e.event_name) continue;
|
|
689
|
+
stmt.run(args.run_id, args.task_id, e.event_name, e.detail || "");
|
|
690
|
+
}
|
|
691
|
+
db.exec("COMMIT");
|
|
692
|
+
} catch (err) {
|
|
693
|
+
db.exec("ROLLBACK");
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
return { ok: true, count: args.events.length };
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
commands["record-run-end"] = (db, args) => {
|
|
700
|
+
if (!args.run_id) throw usageError("run_id is required.");
|
|
701
|
+
const status = args.status || "completed";
|
|
702
|
+
if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
|
|
703
|
+
throw usageError("status must be completed, failed, parked, or timed_out.");
|
|
704
|
+
}
|
|
705
|
+
db.prepare(
|
|
706
|
+
`UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
|
|
707
|
+
WHERE run_id = ?`
|
|
708
|
+
).run(status, args.run_id);
|
|
709
|
+
return { ok: true, run_id: args.run_id, status };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
commands["get-run-timeline"] = (db, args) => {
|
|
713
|
+
if (!args.run_id && !args.task_id) throw usageError("run_id or task_id is required.");
|
|
714
|
+
let runs;
|
|
715
|
+
if (args.run_id) {
|
|
716
|
+
runs = db.prepare("SELECT * FROM workflow_runs WHERE run_id = ?").all(args.run_id);
|
|
717
|
+
} else {
|
|
718
|
+
runs = db.prepare(
|
|
719
|
+
"SELECT * FROM workflow_runs WHERE task_id = ? ORDER BY launched_at"
|
|
720
|
+
).all(args.task_id);
|
|
721
|
+
}
|
|
722
|
+
const runIds = runs.map((r) => r.run_id);
|
|
723
|
+
let events = [];
|
|
724
|
+
if (runIds.length > 0) {
|
|
725
|
+
const placeholders = runIds.map(() => "?").join(",");
|
|
726
|
+
events = db.prepare(
|
|
727
|
+
`SELECT run_id, event_name, detail, created_at FROM run_events
|
|
728
|
+
WHERE run_id IN (${placeholders}) ORDER BY created_at`
|
|
729
|
+
).all(...runIds);
|
|
730
|
+
}
|
|
731
|
+
return { ok: true, runs, events };
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
commands["record-platform-failure"] = (db, args) => {
|
|
735
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
736
|
+
if (!args.error_message) throw usageError("error_message is required.");
|
|
737
|
+
if (!args.platform_created_at) throw usageError("platform_created_at is required.");
|
|
738
|
+
// Try to correlate with a crew telemetry run by timestamp proximity (±60s).
|
|
739
|
+
// The workflow doesn't know its platform run_id, so we match on launch time.
|
|
740
|
+
let crewRunId = args.crew_run_id || null;
|
|
741
|
+
let taskId = args.task_id || null;
|
|
742
|
+
let workflow = args.workflow || null;
|
|
743
|
+
if (!crewRunId) {
|
|
744
|
+
const match = db.prepare(
|
|
745
|
+
`SELECT run_id, task_id, workflow FROM workflow_runs
|
|
746
|
+
WHERE ABS(strftime('%s', launched_at) - strftime('%s', ?)) < 60
|
|
747
|
+
ORDER BY ABS(strftime('%s', launched_at) - strftime('%s', ?))
|
|
748
|
+
LIMIT 1`
|
|
749
|
+
).get(args.platform_created_at, args.platform_created_at);
|
|
750
|
+
if (match) {
|
|
751
|
+
crewRunId = match.run_id;
|
|
752
|
+
taskId = taskId || match.task_id;
|
|
753
|
+
workflow = workflow || match.workflow;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
db.prepare(
|
|
757
|
+
`INSERT INTO platform_run_failures
|
|
758
|
+
(platform_run_id, crew_run_id, task_id, workflow, error_message, platform_created_at)
|
|
759
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
760
|
+
ON CONFLICT(platform_run_id) DO UPDATE SET
|
|
761
|
+
error_message = excluded.error_message,
|
|
762
|
+
detected_at = datetime('now')`
|
|
763
|
+
).run(args.platform_run_id, crewRunId, taskId, workflow, args.error_message, args.platform_created_at);
|
|
764
|
+
return { ok: true, platform_run_id: args.platform_run_id, crew_run_id: crewRunId, task_id: taskId };
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
commands["get-platform-failures"] = (db, args) => {
|
|
768
|
+
let failures;
|
|
769
|
+
if (args.task_id) {
|
|
770
|
+
failures = db.prepare(
|
|
771
|
+
"SELECT * FROM platform_run_failures WHERE task_id = ? ORDER BY detected_at DESC"
|
|
772
|
+
).all(args.task_id);
|
|
773
|
+
} else if (args.platform_run_id) {
|
|
774
|
+
failures = db.prepare(
|
|
775
|
+
"SELECT * FROM platform_run_failures WHERE platform_run_id = ?"
|
|
776
|
+
).all(args.platform_run_id);
|
|
777
|
+
} else {
|
|
778
|
+
const limit = Math.min(parseInt(args.limit) || 50, 200);
|
|
779
|
+
failures = db.prepare(
|
|
780
|
+
"SELECT * FROM platform_run_failures ORDER BY detected_at DESC LIMIT ?"
|
|
781
|
+
).all(limit);
|
|
782
|
+
}
|
|
783
|
+
return { ok: true, failures };
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
commands["mark-failure-retried"] = (db, args) => {
|
|
787
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
788
|
+
db.prepare(
|
|
789
|
+
`UPDATE platform_run_failures
|
|
790
|
+
SET retry_count = retry_count + 1, last_retry_at = datetime('now')
|
|
791
|
+
WHERE platform_run_id = ?`
|
|
792
|
+
).run(args.platform_run_id);
|
|
793
|
+
return { ok: true, platform_run_id: args.platform_run_id };
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
commands["retry-platform-failure"] = (db, args) => {
|
|
797
|
+
if (!args.platform_run_id) throw usageError("platform_run_id is required.");
|
|
798
|
+
const maxRetries = parseInt(args.max_retries) || 3;
|
|
799
|
+
const failure = db.prepare(
|
|
800
|
+
"SELECT * FROM platform_run_failures WHERE platform_run_id = ?"
|
|
801
|
+
).get(args.platform_run_id);
|
|
802
|
+
if (!failure) throw usageError("platform_run_id not found.");
|
|
803
|
+
if (!failure.task_id) {
|
|
804
|
+
return { ok: true, action: "skipped", reason: "no task correlated" };
|
|
805
|
+
}
|
|
806
|
+
if (failure.retry_count >= maxRetries) {
|
|
807
|
+
// Park the task — transient failures are not resolving.
|
|
808
|
+
const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(failure.task_id);
|
|
809
|
+
if (task && task.state !== "parked") {
|
|
810
|
+
const msg = "Platform workflow failed " + (failure.retry_count + 1) + "x: " +
|
|
811
|
+
failure.error_message.substring(0, 200);
|
|
812
|
+
db.prepare("UPDATE tasks SET state = 'parked', updated_at = datetime('now') WHERE id = ?")
|
|
813
|
+
.run(failure.task_id);
|
|
814
|
+
db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
|
|
815
|
+
db.prepare(
|
|
816
|
+
`INSERT INTO events (task_id, event_type, message) VALUES (?, 'parked', ?)`
|
|
817
|
+
).run(failure.task_id, msg);
|
|
818
|
+
}
|
|
819
|
+
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count };
|
|
820
|
+
}
|
|
821
|
+
// Clear reservation and re-queue for retry.
|
|
822
|
+
db.exec("BEGIN");
|
|
823
|
+
try {
|
|
824
|
+
db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
|
|
825
|
+
db.prepare(
|
|
826
|
+
"UPDATE tasks SET state = 'todo', updated_at = datetime('now') WHERE id = ? AND state != 'done'"
|
|
827
|
+
).run(failure.task_id);
|
|
828
|
+
db.prepare(
|
|
829
|
+
`UPDATE platform_run_failures
|
|
830
|
+
SET retry_count = retry_count + 1, last_retry_at = datetime('now')
|
|
831
|
+
WHERE platform_run_id = ?`
|
|
832
|
+
).run(args.platform_run_id);
|
|
833
|
+
db.exec("COMMIT");
|
|
834
|
+
} catch (e) {
|
|
835
|
+
db.exec("ROLLBACK");
|
|
836
|
+
throw e;
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
ok: true,
|
|
840
|
+
action: "requeued",
|
|
841
|
+
task_id: failure.task_id,
|
|
842
|
+
retry_count: failure.retry_count + 1,
|
|
843
|
+
};
|
|
844
|
+
};
|
|
845
|
+
|
|
609
846
|
commands["park-task"] = (db, args) => {
|
|
610
847
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
611
848
|
const message = (args.message ?? "").trim();
|
package/lib/schema.sql
CHANGED
|
@@ -116,3 +116,48 @@ CREATE TABLE IF NOT EXISTS dispatch_reservations (
|
|
|
116
116
|
);
|
|
117
117
|
CREATE INDEX IF NOT EXISTS dispatch_reservations_expires_idx
|
|
118
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.
|
|
@@ -14,10 +29,31 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
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
31
|
- Call workflow_launch_async with scriptPath={claim.scriptPath} and args={claim.args}. Record the returned run_id.
|
|
17
|
-
- Immediately
|
|
18
|
-
`node {crewHome}/lib/crew-api.js --crew-home {crewHome}
|
|
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.
|
|
19
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.
|
|
20
36
|
|
|
21
|
-
If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH
|
|
22
|
-
|
|
23
|
-
5.
|
|
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/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
|
|
@@ -1032,9 +1100,14 @@ while (i < STEPS.length) {
|
|
|
1032
1100
|
);
|
|
1033
1101
|
if (!claimResult.claimed) {
|
|
1034
1102
|
log("Standing down — task " + taskId + " was already claimed by another run");
|
|
1103
|
+
await telemetryEnd("duplicate");
|
|
1035
1104
|
return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
|
|
1036
1105
|
}
|
|
1037
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();
|
|
1038
1111
|
} else {
|
|
1039
1112
|
const claimResult = await agent(
|
|
1040
1113
|
"Claim a session for this task step.\n" +
|
|
@@ -2213,5 +2286,6 @@ await agent(
|
|
|
2213
2286
|
);
|
|
2214
2287
|
|
|
2215
2288
|
log("Chore workflow complete for task " + taskId);
|
|
2289
|
+
await telemetryEnd("completed");
|
|
2216
2290
|
await agent("Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB, { key: "cleanup-pins", label: "Cleaning pinned scripts" });
|
|
2217
2291
|
return { status: "ok", task_id: taskId, message: "Chore workflow complete for " + taskTitle };
|
|
@@ -141,6 +141,54 @@ function parseBoardJson(boardReturn) {
|
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// Deterministic JSON extraction (2026-09-13): an agent ferrying CLI stdout
|
|
145
|
+
// sometimes wraps it in framing text ("Here is the output:\n{...}") instead
|
|
146
|
+
// of returning it verbatim. A bare JSON.parse then throws and the dispatcher
|
|
147
|
+
// drops a reservation it actually acquired — a lost tick, and (before the
|
|
148
|
+
// atomic reclaim above) a stale row that blocked redispatch permanently.
|
|
149
|
+
// This extractor is mechanical, not interpretive: verbatim parse first,
|
|
150
|
+
// then scan for the first balanced {...} candidate that parses. Throws only
|
|
151
|
+
// when no parseable object exists — the caller fails closed.
|
|
152
|
+
function extractJsonObject(raw) {
|
|
153
|
+
if (raw !== null && typeof raw === "object") return raw;
|
|
154
|
+
if (typeof raw !== "string") {
|
|
155
|
+
throw new Error("extractJsonObject: expected a string or object, got " +
|
|
156
|
+
(raw === null ? "null" : typeof raw));
|
|
157
|
+
}
|
|
158
|
+
try { return JSON.parse(raw.trim()); } catch (e) { /* fall through to scan */ }
|
|
159
|
+
var start = raw.indexOf("{");
|
|
160
|
+
while (start !== -1) {
|
|
161
|
+
var end = matchJsonBrace(raw, start);
|
|
162
|
+
if (end !== -1) {
|
|
163
|
+
try { return JSON.parse(raw.slice(start, end + 1)); } catch (e2) { /* keep scanning */ }
|
|
164
|
+
}
|
|
165
|
+
start = raw.indexOf("{", start + 1);
|
|
166
|
+
}
|
|
167
|
+
throw new Error("extractJsonObject: no parseable JSON object found in agent output");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Balanced-brace scan honoring string literals and escapes. Returns the
|
|
171
|
+
// index of the brace matching the open brace at `start`, or -1.
|
|
172
|
+
function matchJsonBrace(s, start) {
|
|
173
|
+
var depth = 0, inStr = false, esc = false;
|
|
174
|
+
for (var i = start; i < s.length; i++) {
|
|
175
|
+
var ch = s[i];
|
|
176
|
+
if (inStr) {
|
|
177
|
+
if (esc) esc = false;
|
|
178
|
+
else if (ch === "\\") esc = true;
|
|
179
|
+
else if (ch === "\"") inStr = false;
|
|
180
|
+
} else if (ch === "\"") {
|
|
181
|
+
inStr = true;
|
|
182
|
+
} else if (ch === "{") {
|
|
183
|
+
depth++;
|
|
184
|
+
} else if (ch === "}") {
|
|
185
|
+
depth--;
|
|
186
|
+
if (depth === 0) return i;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return -1;
|
|
190
|
+
}
|
|
191
|
+
|
|
144
192
|
// Deterministic board projection — the ONLY place task records are shaped.
|
|
145
193
|
// Pure function, no I/O: covered by tests/board-projection.test.js. The
|
|
146
194
|
// retry field is dashboard-owned state (consecutive failures / rejections
|
|
@@ -807,6 +855,48 @@ results.forEach(function(r) {
|
|
|
807
855
|
recommended.push(r);
|
|
808
856
|
});
|
|
809
857
|
var completed = results.filter(function(r) { return r.action === "completed"; });
|
|
858
|
+
|
|
859
|
+
// Atomic reservation: create a dispatch reservation for each recommended task
|
|
860
|
+
// BEFORE returning. This closes the launch→reservation race — the worker no
|
|
861
|
+
// longer needs to create reservations (it updates the run_id after launch).
|
|
862
|
+
// If the worker dies or times out, the reservation expires via TTL.
|
|
863
|
+
// Note: run_id uses a deterministic placeholder (no wall-clock — see
|
|
864
|
+
// tests/determinism.test.js); the worker updates it with the real run_id
|
|
865
|
+
// via acknowledge-dispatch-run after workflow_launch_async returns.
|
|
866
|
+
if (recommended.length > 0) {
|
|
867
|
+
var acquired = [];
|
|
868
|
+
for (var i = 0; i < recommended.length; i++) {
|
|
869
|
+
var rec = recommended[i];
|
|
870
|
+
var reserveCmd = crewCmd("reserve-dispatch", {
|
|
871
|
+
task_id: rec.task_id,
|
|
872
|
+
run_id: "pending-dispatch",
|
|
873
|
+
workflow: rec.workflow
|
|
874
|
+
});
|
|
875
|
+
try {
|
|
876
|
+
var reserveOut = await agent(
|
|
877
|
+
"Create a dispatch reservation.\nRun in shell and return the stdout verbatim:\n" + reserveCmd,
|
|
878
|
+
{ key: "reserve-" + rec.task_id.slice(0, 8), label: "Reserving dispatch for " + rec.task_id.slice(0, 8) }
|
|
879
|
+
);
|
|
880
|
+
var reserveParsed = extractJsonObject(reserveOut);
|
|
881
|
+
if (reserveParsed && reserveParsed.acquired) {
|
|
882
|
+
log("Reserved task " + rec.task_id.slice(0, 8));
|
|
883
|
+
acquired.push(rec);
|
|
884
|
+
} else {
|
|
885
|
+
// FAIL CLOSED: Another dispatcher owns this task. Do not recommend it.
|
|
886
|
+
log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation not acquired (another dispatcher owns it)");
|
|
887
|
+
}
|
|
888
|
+
} catch (e) {
|
|
889
|
+
// FAIL CLOSED: If we cannot reserve — or cannot deterministically read
|
|
890
|
+
// the reservation result — we cannot safely recommend. The task stays
|
|
891
|
+
// eligible; a row created before the parse failure expires via TTL and
|
|
892
|
+
// is reclaimable by the next tick (atomic reclaim in reserve-dispatch).
|
|
893
|
+
log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation failed: " + e.message);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Only recommend tasks we actually acquired.
|
|
897
|
+
recommended = acquired;
|
|
898
|
+
}
|
|
899
|
+
|
|
810
900
|
var msg = "Dispatch complete.";
|
|
811
901
|
if (recommended.length > 0) {
|
|
812
902
|
msg += " Recommended: " + recommended.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
|