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 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 after the cron worker launches its workflow. The dispatcher skips reserved tasks on subsequent ticks without this, a task whose workflow takes longer than the poll interval to self-claim would be re-dispatched every tick.
195
+ Create a dispatch reservation for a task. Called by the dispatcher workflow BEFORE the worker launches the workflowthe 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 that was dispatched. |
200
- | `run_id` | string | Required. The workflow run ID returned by `workflow_launch_async`. |
201
- | `workflow` | string | Required. Which workflow was launched (`standard`, `bugfix`, `chore`). |
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
- Upserts: re-reserving the same task replaces the existing reservation. Returns the reservation with `dispatched_at` and `expires_at` timestamps.
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
- db.prepare(
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, workflow = excluded.workflow,
590
- dispatched_at = excluded.dispatched_at, expires_at = excluded.expires_at`
591
- ).run(args.task_id, args.run_id, args.workflow, dispatchedAt, expiresAt);
592
- return { ok: true, task_id: args.task_id, run_id: args.run_id, workflow: args.workflow, dispatched_at: dispatchedAt, expires_at: expiresAt };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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.
@@ -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 create a dispatch reservation so the next tick does not re-dispatch before the workflow self-claims (claims can take 15+ minutes for cron-launched runs). Run in shell:
18
- `node {crewHome}/lib/crew-api.js --crew-home {crewHome} reserve-dispatch --json '{"task_id": "{claim.task_id}", "run_id": "<run_id>", "workflow": "{claim.workflow}"}'`
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. Exit silently.
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.
@@ -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(", ") + ".";