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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
- - The launched workflow self-claims the task as its first action. 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.
18
-
19
- If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH
20
-
21
- 5. Exit silently.
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.
@@ -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
  {
@@ -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(", ") + ".";
@@ -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
  {