muse-crew 0.14.4 → 0.14.6

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.
@@ -1,405 +0,0 @@
1
- #!/usr/bin/env node
2
- // retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5).
3
- //
4
- // The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
5
- // lock handling, journaling, diff generation, ledger writes, mirroring, and
6
- // cleanup by following template instructions. A missed step (or a tick dying
7
- // mid-protocol) left the lock held or the state inconsistent.
8
- //
9
- // This command owns all deterministic transitions with try/finally lock
10
- // release. Only the unavoidable `artifact_edit` tool call remains
11
- // agent-mediated (it requires the artifact namespace, which this process
12
- // cannot load).
13
- //
14
- // Two phases:
15
- // Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
16
- // journal intent, generate diff. Outputs JSON with the diff path/sha
17
- // for the agent to feed to artifact_edit. The lock is HELD on success
18
- // (the agent must call phase 2 to release it).
19
- // Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
20
- // write the outcome notes, append the ledger entry, mirror verification,
21
- // and RELEASE the lock in a finally block.
22
- //
23
- // Crash safety: if the agent dies between phases, the lock expires after
24
- // 600s and the note journal (`publish: retry-intended` without
25
- // `publish: retry-issued`) lets the next scan mirror verification-requested
26
- // without re-triggering — exactly one retry, enforced from notes.
27
- //
28
- // Usage:
29
- // node retry-publish.js --crew-home <path> --task-id <uuid> --phase prepare
30
- // node retry-publish.js --crew-home <path> --task-id <uuid> --phase complete --edit-outcome <accepted|refused> [--refusal-text <text>]
31
- //
32
- // Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
33
-
34
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
35
- import { join } from "path";
36
- import { execFileSync } from "child_process";
37
- import { randomUUID as uuid } from "crypto";
38
-
39
- const args = process.argv.slice(2);
40
- function arg(name, required = true, def = undefined) {
41
- const i = args.indexOf(name);
42
- if (i === -1 || i + 1 >= args.length) {
43
- if (required) { console.error(`missing required arg ${name}`); process.exit(2); }
44
- return def;
45
- }
46
- return args[i + 1];
47
- }
48
- function fail(code, msg, exitCode = 1) {
49
- process.stdout.write(JSON.stringify({ ok: false, code, message: msg }) + "\n");
50
- process.exit(exitCode);
51
- }
52
-
53
- // --help: handled before any required-arg parsing so
54
- // `node retry-publish.js --help` always exits 0 (entry-gate contract).
55
- if (args.includes("--help")) {
56
- console.log(
57
- "usage: node retry-publish.js --crew-home <path> --task-id <uuid> --phase <prepare|complete>\n" +
58
- " prepare: acquire the merge lock, re-check HEAD, re-classify, journal intent, emit the diff\n" +
59
- " complete: --edit-outcome <accepted|refused> [--refusal-text <text>] — write the outcome,\n" +
60
- " append the ledger entry, mirror verification, and release the lock\n" +
61
- "exit 0 = success, 1 = terminal stop, 2 = usage error"
62
- );
63
- process.exit(0);
64
- }
65
-
66
- const crewHome = arg("--crew-home");
67
- const taskId = arg("--task-id");
68
- const phase = arg("--phase");
69
-
70
- if (!["prepare", "complete"].includes(phase)) {
71
- console.error("phase must be 'prepare' or 'complete'");
72
- process.exit(2);
73
- }
74
-
75
- // Retry protocol: phase-1 state is a persistent-disk pin
76
- // ($CREW_HOME/.pins/ — never /tmp: tmpfs is wiped on cell reboot, and the
77
- // lock release depends on phase 2 reading this state).
78
- function pinsDir() {
79
- const dir = join(crewHome, ".pins");
80
- mkdirSync(dir, { recursive: true });
81
- return dir;
82
- }
83
- function retryStatePath() {
84
- return join(pinsDir(), `retry-publish-${taskId}.json`);
85
- }
86
-
87
- // --- Shared helpers ---------------------------------------------------------
88
-
89
- function nowISO() { return new Date().toISOString(); }
90
-
91
- function writePublishNote(db, taskId, message) {
92
- db.prepare(
93
- `INSERT INTO events (id, type, task_id, identity, message, timestamp)
94
- VALUES (?, 'note', ?, NULL, ?, ?)`
95
- ).run(uuid(), taskId, message, nowISO());
96
- }
97
-
98
- // Load the crew DB. We use better-sqlite3 via the crew-api's own dependency
99
- // resolution — fall back to a clear error if unavailable.
100
- let Database;
101
- try {
102
- Database = (await import("better-sqlite3")).default;
103
- } catch {
104
- fail("db-unavailable", "better-sqlite3 is not available to retry-publish.js");
105
- }
106
- const dbPath = join(crewHome, "crew.db");
107
- if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
108
- const db = new Database(dbPath);
109
-
110
- const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
111
-
112
- function runMergeLock(action) {
113
- const lockScript = join(crewHome, "lib", "merge-lock.sh");
114
- try {
115
- execFileSync(lockScript, [action, taskId], {
116
- env: { ...process.env, CREW_HOME: crewHome },
117
- encoding: "utf8",
118
- timeout: 30000,
119
- });
120
- return { ok: true };
121
- } catch (e) {
122
- return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
123
- }
124
- }
125
-
126
- // --- Phase 1: prepare --------------------------------------------------------
127
- // Acquires the lock, re-checks HEAD, re-classifies, journals intent,
128
- // generates the diff. On success the lock is HELD and the output carries
129
- // the diff path/sha for the agent's artifact_edit child.
130
-
131
- if (phase === "prepare") {
132
- // Derive the unknown attempt (same derivation as scan-publish-unknown).
133
- const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
134
- if (!task) fail("unknown-task", `task ${taskId} not found`);
135
- if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
136
-
137
- const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
138
- const slug = project && project.deploy_slug;
139
- if (!slug) fail("no-slug", "project has no deploy_slug");
140
- const repoPath = (project && project.repo_path) || null;
141
-
142
- const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
143
- let entries = [];
144
- try {
145
- entries = readFileSync(ledgerPath, "utf8").split("\n")
146
- .filter((l) => l.trim().length > 0)
147
- .map((l) => JSON.parse(l))
148
- .filter((e) => e.task_id === taskId);
149
- } catch (e) {
150
- fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
151
- }
152
- const unknownEntry = entries[entries.length - 1];
153
- if (!unknownEntry || unknownEntry.outcome !== "unknown") {
154
- fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
155
- }
156
- const commit = unknownEntry.commit;
157
- const attempt = unknownEntry.attempt || null;
158
- if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
159
- fail("bad-commit", "unknown ledger entry carries no usable commit hash");
160
- }
161
-
162
- // 1. Re-check HEAD: must still be the retry target.
163
- let head;
164
- try {
165
- head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
166
- } catch (e) {
167
- fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
168
- }
169
- if (head !== commit) {
170
- writePublishNote(db, taskId,
171
- `publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
172
- process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
173
- process.exit(0);
174
- }
175
-
176
- // 2. Acquire the merge lock.
177
- const lock = runMergeLock("acquire");
178
- if (!lock.ok) {
179
- process.stdout.write(JSON.stringify({
180
- ok: true, phase: "prepare", outcome: "lock-held",
181
- message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
182
- }) + "\n");
183
- process.exit(0);
184
- }
185
-
186
- // From here to the end of phase 1, the lock is held. Any failure must
187
- // release it — try/finally.
188
- let lockHeld = true;
189
- const releaseLock = () => {
190
- if (lockHeld) {
191
- runMergeLock("release");
192
- lockHeld = false;
193
- }
194
- };
195
-
196
- try {
197
- // 3. Inside the lock, re-classify.
198
- let base = EMPTY_TREE;
199
- try {
200
- const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
201
- if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
202
- } catch (e) {
203
- releaseLock();
204
- fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
205
- }
206
-
207
- const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
208
- // Find the trigger entry for the manifest baseline: the OLDEST submitted
209
- // entry by ts (D1, 2026-09-19; entry_kind cut 2026-09-20 — the retired
210
- // kind-preference could never change the bound anchor) — it binds
211
- // issued_at (falling back to its ts when the capture was unobserved),
212
- // the same anchor verify-publish.js and findTriggerEntry use.
213
- // agent_id is null on the issuance entry (no receipt observed at trigger
214
- // time) — the baseline comes from manifest_before, never agent_id.
215
- const submitted = entries.filter((e) =>
216
- e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
217
- submitted.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
218
- const trigger = submitted[0] || null;
219
- const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
220
-
221
- // Find the park note timestamp for quiesce calculation.
222
- const parkNote = db.prepare(
223
- `SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
224
- AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
225
- ORDER BY timestamp DESC LIMIT 1`
226
- ).get(taskId);
227
- if (!parkNote) {
228
- releaseLock();
229
- fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
230
- }
231
-
232
- const classifierArgs = [
233
- classifierPath,
234
- "--slug", slug,
235
- "--commit", commit,
236
- "--base", base,
237
- "--trigger-ts", trigger ? (trigger.issued_at || trigger.ts) : parkNote.timestamp,
238
- "--park-ts", parkNote.timestamp,
239
- "--repo-path", repoPath,
240
- ];
241
- if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
242
-
243
- let classification;
244
- try {
245
- const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
246
- classification = JSON.parse(out);
247
- } catch (e) {
248
- releaseLock();
249
- fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
250
- }
251
-
252
- if (!classification.ok || classification.decision !== "provably-dropped") {
253
- // Not dropped — release the lock and route via the existing
254
- // record-retry-recheck path (the code owns the routing).
255
- releaseLock();
256
- process.stdout.write(JSON.stringify({
257
- ok: true, phase: "prepare", outcome: "not-dropped",
258
- decision: classification.decision,
259
- message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
260
- classification,
261
- }) + "\n");
262
- process.exit(0);
263
- }
264
-
265
- // 3b. Capture the pre-retry manifest baseline (inside the lock).
266
- let retryBaseline = null;
267
- try {
268
- const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
269
- if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
270
- retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
271
- }
272
- } catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
273
-
274
- // 4. Journal the intent BEFORE the trigger.
275
- writePublishNote(db, taskId,
276
- `publish: retry-intended ${commit} ${nowISO()} — re-issuing the dropped edit once; if this tick dies before the trigger returns, the next scan mirrors verification-requested rather than re-triggering.`);
277
-
278
- // 5. Regenerate the diff deterministically.
279
- const diffPath = `/tmp/retry-diff-${taskId}.txt`;
280
- let diffMeta;
281
- try {
282
- const out = execFileSync("node", [
283
- join(crewHome, "lib", "compute-publish-diff.js"),
284
- "--repo-path", repoPath,
285
- "--base", base,
286
- "--out", diffPath,
287
- ], { encoding: "utf8" });
288
- diffMeta = JSON.parse(out);
289
- } catch (e) {
290
- releaseLock();
291
- fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
292
- }
293
- if (!diffMeta.ok) {
294
- releaseLock();
295
- fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
296
- }
297
-
298
- // Success: lock remains HELD. The agent spawns the artifact_edit child
299
- // with the diff, then calls phase 2 (complete) to finish.
300
- // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
301
- // but phase 2 needs the exact commit/attempt/baseline/ledger path).
302
- const statePath = retryStatePath();
303
- writeFileSync(statePath, JSON.stringify({
304
- task_id: taskId, commit, attempt, slug, repoPath,
305
- project_id: project.id, ledger_path: ledgerPath,
306
- retry_baseline: retryBaseline,
307
- diff_path: diffPath, diff_sha256: diffMeta.sha256,
308
- prepared_at: nowISO(),
309
- }));
310
-
311
- process.stdout.write(JSON.stringify({
312
- ok: true, phase: "prepare", outcome: "ready",
313
- task_id: taskId, commit, attempt,
314
- diff_path: diffPath, diff_sha256: diffMeta.sha256,
315
- slug,
316
- message: "lock held; spawn the artifact_edit child with the diff, then call phase 2 (complete)",
317
- }) + "\n");
318
- process.exit(0);
319
- } catch (e) {
320
- releaseLock();
321
- throw e;
322
- }
323
- }
324
-
325
- // --- Phase 2: complete -------------------------------------------------------
326
- // Given the artifact_edit outcome, write the outcome notes, append the
327
- // ledger entry, mirror verification, and RELEASE the lock (finally).
328
-
329
- if (phase === "complete") {
330
- const editOutcome = arg("--edit-outcome");
331
- if (!["accepted", "refused"].includes(editOutcome)) {
332
- console.error("--edit-outcome must be 'accepted' or 'refused'");
333
- process.exit(2);
334
- }
335
- const refusalText = arg("--refusal-text", false, "");
336
-
337
- const statePath = retryStatePath();
338
- if (!existsSync(statePath)) {
339
- fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
340
- }
341
- const state = JSON.parse(readFileSync(statePath, "utf8"));
342
- const { commit, attempt, slug, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
343
-
344
- let lockHeld = true;
345
- const releaseLock = () => {
346
- if (lockHeld) {
347
- runMergeLock("release");
348
- lockHeld = false;
349
- }
350
- };
351
-
352
- try {
353
- if (editOutcome === "refused") {
354
- // Conclusive negative: rejected, not unknown. Terminal.
355
- writePublishNote(db, taskId,
356
- `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
357
- process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
358
- } else {
359
- // The child ended after the edit call with no explicit refusal —
360
- // fire-and-forget, same shape as the first attempt.
361
- writePublishNote(db, taskId,
362
- `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
363
-
364
- // Mirror verification with a settle window.
365
- const settleMs = Number(process.env.CREW_RETRY_SETTLE_MS) > 0
366
- ? Number(process.env.CREW_RETRY_SETTLE_MS)
367
- : 20 * 60 * 1000;
368
- const notBefore = new Date(Date.now() + settleMs).toISOString();
369
- const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
370
- writePublishNote(db, taskId,
371
- `publish: verification-requested ${commit} attempt=${retryAttempt} not-before=${notBefore} — unknown-recovery retried the dropped edit once; content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
372
-
373
- // Append the -retry1 ledger entry with the pre-retry baseline (required).
374
- const ledgerEntry = {
375
- ts: nowISO(),
376
- task_id: taskId,
377
- workflow: "unknown-recovery",
378
- slug,
379
- commit,
380
- attempt: retryAttempt,
381
- agent_id: null,
382
- applied_report: null,
383
- outcome: "submitted",
384
- manifest_before: retryBaseline,
385
- detail: "retry trigger re-issued after provably-dropped classification",
386
- };
387
- try {
388
- appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
389
- } catch (e) {
390
- // Best-effort: the note journal is the source of truth, not the ledger.
391
- console.error(`ledger append failed (non-fatal): ${e.message}`);
392
- }
393
-
394
- process.stdout.write(JSON.stringify({
395
- ok: true, phase: "complete", outcome: "issued",
396
- commit, attempt: retryAttempt, not_before: notBefore,
397
- }) + "\n");
398
- }
399
- } finally {
400
- releaseLock();
401
- // Clean up the phase-1 state file (best-effort).
402
- try { unlinkSync(statePath); } catch {}
403
- }
404
- process.exit(0);
405
- }