muse-crew 0.7.3 → 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
@@ -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
  |-------|------|-------|
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.7.3",
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,
@@ -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
  }