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 +1 -1
- package/lib/crew-api.js +15 -7
- package/package.json +1 -1
- package/workflows/crew-dispatch.js +53 -3
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
|
|
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
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
|
594
|
-
|
|
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
|
@@ -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 =
|
|
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
|
|
842
|
-
//
|
|
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
|
}
|