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 +5 -3
- package/lib/crew-api.js +55 -9
- package/lib/schema.sql +11 -0
- 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
|
|-------|------|-------|
|
|
@@ -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
|
|
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
|
|
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,
|
|
@@ -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
|
-
//
|
|
731
|
-
//
|
|
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
|
@@ -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
|
}
|