muse-crew 0.7.3 → 0.7.5

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
@@ -194,7 +194,7 @@ Record that a poll tick occurred. Takes no arguments. Used by the dispatcher at
194
194
 
195
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
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.
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.
198
198
 
199
199
  | Field | Type | Notes |
200
200
  |-------|------|-------|
@@ -214,7 +214,7 @@ Update a dispatch reservation with the real platform workflow run ID. Called by
214
214
  | `task_id` | string | Required. The task that was dispatched. |
215
215
  | `run_id` | string | Required. The real platform run ID (e.g. `workflow-run-xxx`). Must not be `"pending-dispatch"`. |
216
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 }`.
217
+ Fails closed: throws if no reservation exists for the task (launching without a reservation is forbidden). Also records a durable platform run -> task mapping (`platform_run_tasks`) — the worker is the only party that ever knows both IDs at once, and the poll loop's platform-failure monitor resolves the task through this mapping, so a dead run is requeued on the next tick instead of waiting out the zombie-session sweep. Returns `{ ok: true, task_id, run_id }`.
218
218
 
219
219
  ### `clear-reservation`
220
220
 
@@ -295,7 +295,7 @@ The platform records workflow run status in `runtime.workflow_runs` — separate
295
295
 
296
296
  ### `record-platform-failure`
297
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.
298
+ Record a platform workflow run failure. Correlates the task in this order: (1) the durable `platform_run_tasks` mapping written by `acknowledge-dispatch-run`, (2) timestamp proximity (±60s) between `platform_created_at` and crew telemetry launch time (fallback for runs launched before the mapping existed), (3) an explicit `crew_run_id`/`task_id` when provided. A failure that correlates to no task is kept with `task_id: null` and reported as skipped by retry.
299
299
 
300
300
  | Field | Type | Notes |
301
301
  |-------|------|-------|
@@ -324,6 +324,8 @@ Returns `{ ok: true, failures: [...] }`.
324
324
 
325
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
326
 
327
+ **Supersede guard:** if a newer platform run has been linked for this task since this failure's run, the task is already owned by the successor — the retry is skipped instead of clobbering live work (a late detection of an old dead run never resets a redispatched task).
328
+
327
329
  | Field | Type | Notes |
328
330
  |-------|------|-------|
329
331
  | `platform_run_id` | string | Required. |
package/lib/crew-api.js CHANGED
@@ -582,16 +582,24 @@ 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
- // 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.
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.
590
593
  const info = db.prepare(
591
594
  `INSERT INTO dispatch_reservations (task_id, run_id, workflow, dispatched_at, expires_at)
592
595
  VALUES (?, ?, ?, ?, ?)
593
- ON CONFLICT(task_id) DO NOTHING`
594
- ).run(args.task_id, args.run_id, args.workflow, dispatchedAt, expiresAt);
596
+ ON CONFLICT(task_id) DO UPDATE SET
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());
595
603
  const acquired = info.changes > 0;
596
604
  return {
597
605
  ok: true,
@@ -620,6 +628,17 @@ commands["acknowledge-dispatch-run"] = (db, args) => {
620
628
  if (info.changes === 0) {
621
629
  throw usageError("no reservation exists for task_id — launch without reservation is forbidden.");
622
630
  }
631
+ // Durable platform run -> task mapping (2026-09-13): the worker is the only
632
+ // party that ever knows both IDs at once. record-platform-failure resolves
633
+ // the task through this mapping instead of timestamp proximity, so a dead
634
+ // platform run is requeued on the next tick instead of waiting out the
635
+ // 1-hour zombie-session sweep.
636
+ db.prepare(
637
+ `INSERT INTO platform_run_tasks (platform_run_id, task_id)
638
+ VALUES (?, ?)
639
+ ON CONFLICT(platform_run_id) DO UPDATE SET
640
+ task_id = excluded.task_id, linked_at = datetime('now')`
641
+ ).run(args.run_id, args.task_id);
623
642
  return { ok: true, task_id: args.task_id, run_id: args.run_id };
624
643
  };
625
644
 
@@ -727,11 +746,19 @@ commands["record-platform-failure"] = (db, args) => {
727
746
  if (!args.platform_run_id) throw usageError("platform_run_id is required.");
728
747
  if (!args.error_message) throw usageError("error_message is required.");
729
748
  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.
749
+ // Correlate with a crew task. First the durable platform_run_tasks mapping
750
+ // (written by acknowledge-dispatch-run the worker is the only party that
751
+ // ever knows both IDs at once). Timestamp proximity is the fallback for
752
+ // runs launched before the mapping existed.
732
753
  let crewRunId = args.crew_run_id || null;
733
754
  let taskId = args.task_id || null;
734
755
  let workflow = args.workflow || null;
756
+ if (!taskId) {
757
+ const link = db.prepare(
758
+ "SELECT task_id FROM platform_run_tasks WHERE platform_run_id = ?"
759
+ ).get(args.platform_run_id);
760
+ if (link) taskId = link.task_id;
761
+ }
735
762
  if (!crewRunId) {
736
763
  const match = db.prepare(
737
764
  `SELECT run_id, task_id, workflow FROM workflow_runs
@@ -795,6 +822,25 @@ commands["retry-platform-failure"] = (db, args) => {
795
822
  if (!failure.task_id) {
796
823
  return { ok: true, action: "skipped", reason: "no task correlated" };
797
824
  }
825
+ // Supersede guard: if a newer platform run was linked for this task after
826
+ // this failure's run, the task is already owned by the newer run —
827
+ // requeueing here would clobber live work. Skip instead of retrying.
828
+ // (Without this, a late Step-0 detection of an old dead run could reset a
829
+ // task its successor is already working.)
830
+ const myLink = db.prepare(
831
+ "SELECT linked_at, rowid AS rid FROM platform_run_tasks WHERE platform_run_id = ?"
832
+ ).get(args.platform_run_id);
833
+ if (myLink) {
834
+ const newer = db.prepare(
835
+ `SELECT platform_run_id FROM platform_run_tasks
836
+ WHERE task_id = ? AND platform_run_id != ?
837
+ AND (linked_at > ? OR (linked_at = ? AND rowid > ?))
838
+ ORDER BY linked_at DESC, rowid DESC LIMIT 1`
839
+ ).get(failure.task_id, args.platform_run_id, myLink.linked_at, myLink.linked_at, myLink.rid);
840
+ if (newer) {
841
+ return { ok: true, action: "skipped", reason: "superseded by newer run " + newer.platform_run_id };
842
+ }
843
+ }
798
844
  if (failure.retry_count >= maxRetries) {
799
845
  // Park the task — transient failures are not resolving.
800
846
  const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(failure.task_id);
package/lib/schema.sql CHANGED
@@ -161,3 +161,14 @@ CREATE TABLE IF NOT EXISTS platform_run_failures (
161
161
  );
162
162
  CREATE INDEX IF NOT EXISTS platform_run_failures_task_id_idx ON platform_run_failures(task_id);
163
163
  CREATE INDEX IF NOT EXISTS platform_run_failures_detected_at_idx ON platform_run_failures(detected_at);
164
+
165
+ -- Durable platform run -> task mapping (2026-09-13). The poll worker is the
166
+ -- only party that ever knows both IDs at once, so acknowledge-dispatch-run
167
+ -- records the pairing here. record-platform-failure resolves the task through
168
+ -- this mapping; the old timestamp-proximity match is a fallback only.
169
+ CREATE TABLE IF NOT EXISTS platform_run_tasks (
170
+ platform_run_id TEXT PRIMARY KEY,
171
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
172
+ linked_at TEXT NOT NULL DEFAULT (datetime('now'))
173
+ );
174
+ CREATE INDEX IF NOT EXISTS platform_run_tasks_task_id_idx ON platform_run_tasks(task_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
@@ -829,7 +877,7 @@ if (recommended.length > 0) {
829
877
  "Create a dispatch reservation.\nRun in shell and return the stdout verbatim:\n" + reserveCmd,
830
878
  { key: "reserve-" + rec.task_id.slice(0, 8), label: "Reserving dispatch for " + rec.task_id.slice(0, 8) }
831
879
  );
832
- var reserveParsed = typeof reserveOut === "string" ? JSON.parse(reserveOut) : reserveOut;
880
+ var reserveParsed = extractJsonObject(reserveOut);
833
881
  if (reserveParsed && reserveParsed.acquired) {
834
882
  log("Reserved task " + rec.task_id.slice(0, 8));
835
883
  acquired.push(rec);
@@ -838,8 +886,10 @@ if (recommended.length > 0) {
838
886
  log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation not acquired (another dispatcher owns it)");
839
887
  }
840
888
  } catch (e) {
841
- // FAIL CLOSED: If we cannot reserve, we cannot safely recommend.
842
- // The task stays eligible for the next poll.
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).
843
893
  log("SKIPPED task " + rec.task_id.slice(0, 8) + " — reservation failed: " + e.message);
844
894
  }
845
895
  }