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.
- package/AGENTS.md +1 -1
- package/API.md +61 -7
- package/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +244 -1
- package/docs/guide.md +42 -6
- package/docs/publish-unknown-recovery.md +28 -113
- package/docs/publish-verification.md +148 -445
- package/docs/release-integrity.md +5 -3
- package/docs/reviews/critic-0145.md +106 -0
- package/lib/AGENTS.md +4 -8
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +113 -15
- package/lib/crew-api.js +855 -725
- package/lib/publish-note-vocabulary.js +137 -24
- package/lib/schema.sql +19 -0
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +65 -65
- package/seed/crons.json +12 -0
- package/workflows/bugfix.js +146 -740
- package/workflows/chore.js +146 -740
- package/workflows/crew-dispatch.js +36 -0
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/standard.js +146 -763
- package/lib/build-readback-request.js +0 -140
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -405
- package/lib/verify-publish.js +0 -414
package/lib/retry-publish.js
DELETED
|
@@ -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
|
-
}
|