muse-crew 0.13.2 → 0.14.0
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/docs/decisions/AGENTS.md +94 -0
- package/docs/decisions/publish-path.md +1280 -0
- package/docs/decisions/qa-reproduce.md +486 -0
- package/docs/decisions/workflow-core.md +234 -0
- package/docs/publish-unknown-recovery.md +113 -0
- package/docs/visual-verdict.md +6 -7
- package/lib/AGENTS.md +3 -1
- package/lib/classify-publish-absence.js +451 -0
- package/lib/compose-evidence-caption.js +36 -5
- package/lib/crew-api.js +584 -22
- package/lib/crew-release.sh +51 -9
- package/lib/publish-content.js +154 -0
- package/lib/retry-publish.js +370 -0
- package/lib/verify-publish.js +101 -84
- package/package.json +2 -2
- package/seed/cron-body-template.md +57 -2
- package/workflows/AGENTS.md +3 -1
- package/workflows/bugfix.js +301 -592
- package/workflows/chore.js +292 -526
- package/workflows/standard.js +294 -550
- package/workflows/upgrade.js +1 -1
package/lib/crew-release.sh
CHANGED
|
@@ -73,9 +73,12 @@ cmd_init() {
|
|
|
73
73
|
echo "CURRENT: $cur"
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
# ── workflow
|
|
77
|
-
# Every workflow script must parse
|
|
76
|
+
# ── workflow validation ─────────────────────────────────────────────
|
|
77
|
+
# Every workflow script must (a) parse under the workflow-loader emulation
|
|
78
|
+
# and (b) fit the launch budget before a release can install.
|
|
79
|
+
# Exit codes: 10 = parse check failed, 20 = size check failed (0 = pass).
|
|
78
80
|
_validate_workflows() {
|
|
81
|
+
# --- Parse gate (restored 2026-09-18, finding B2) ---
|
|
79
82
|
# The workflow runtime accepts top-level `export`, `return`, and `await`
|
|
80
83
|
# (it wraps scripts in an async function), so neither `node --check` on the
|
|
81
84
|
# raw .js (vacuous for ESM — exits 0 even on blatant syntax errors) nor a
|
|
@@ -83,8 +86,23 @@ _validate_workflows() {
|
|
|
83
86
|
# Emulate the runtime instead: strip `export`, wrap the script in an async
|
|
84
87
|
# function, then node --check the result. Tokenizer errors (e.g. an
|
|
85
88
|
# unterminated string literal) still fail under the wrap.
|
|
89
|
+
#
|
|
90
|
+
# HONESTY NOTE: this is a provisional emulation, not a verification. The
|
|
91
|
+
# platform's actual loading mechanism was never verified (probe blocked,
|
|
92
|
+
# 2026-09-18), so this check cannot prove how the platform loads the
|
|
93
|
+
# workflows. What it DOES prove: the file is syntactically well-formed JS
|
|
94
|
+
# when read the way the runtime claims to read it (strip export, run as one
|
|
95
|
+
# async function body). What it does NOT prove: that the platform actually
|
|
96
|
+
# loads it that way, or that the code runs correctly. The export-strip +
|
|
97
|
+
# async-wrap transform exists precisely because raw `node --check` on ESM
|
|
98
|
+
# `.js` is unreliable — it silently exits 0 on some malformed input — so
|
|
99
|
+
# the transform is what makes the check actually parse.
|
|
100
|
+
# A failure here is a signal to INSPECT THE CODE, never to delete the
|
|
101
|
+
# check: this gate was once removed on a misdiagnosis ("the check
|
|
102
|
+
# false-positives"), and the workflows it rejected turned out to be
|
|
103
|
+
# genuinely broken (2026-09-18, Gate 1 H2 — a true positive).
|
|
86
104
|
local dir="$1"
|
|
87
|
-
local f tmp base
|
|
105
|
+
local f tmp base size
|
|
88
106
|
tmp="$(mktemp -d)"
|
|
89
107
|
for f in "$dir"/workflows/*.js; do
|
|
90
108
|
[ -f "$f" ] || continue
|
|
@@ -96,13 +114,28 @@ _validate_workflows() {
|
|
|
96
114
|
} > "$tmp/$base.js"
|
|
97
115
|
if ! node --check "$tmp/$base.js" 2>"$tmp/$base.err"; then
|
|
98
116
|
cat "$tmp/$base.err" >&2
|
|
99
|
-
echo "VALIDATION FAILED: $f does not parse" >&2
|
|
117
|
+
echo "VALIDATION FAILED [parse]: $f does not parse under the workflow-loader emulation" >&2
|
|
100
118
|
rm -rf "$tmp"
|
|
101
|
-
return
|
|
119
|
+
return 10
|
|
102
120
|
fi
|
|
103
121
|
done
|
|
104
122
|
rm -rf "$tmp"
|
|
105
|
-
|
|
123
|
+
|
|
124
|
+
# --- Size gate (H5, 2026-09-18) ---
|
|
125
|
+
# The platform reads the full workflow file on launch; the platform hard
|
|
126
|
+
# maximum is 262144 bytes (256 KiB) and 245760 bytes (240 KiB) is the
|
|
127
|
+
# project's safety target. The release fails closed if any launchable
|
|
128
|
+
# workflow hits the safety target.
|
|
129
|
+
for f in "$dir"/workflows/*.js; do
|
|
130
|
+
[ -f "$f" ] || continue
|
|
131
|
+
size="$(wc -c < "$f")"
|
|
132
|
+
if [ "$size" -ge 245760 ]; then
|
|
133
|
+
echo "VALIDATION FAILED [size]: $f is $size bytes (>= 245760 byte launch budget)" >&2
|
|
134
|
+
return 20
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
|
|
138
|
+
echo "VALIDATED: workflow scripts parse and fit the launch budget"
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
# ── workflow doc sync ───────────────────────────────────────────────
|
|
@@ -255,10 +288,19 @@ cmd_deploy() {
|
|
|
255
288
|
rm -rf "$staging_dir"
|
|
256
289
|
die "release $hash rejected: registry build failed"
|
|
257
290
|
fi
|
|
258
|
-
# Gate: refuse to install a release whose workflow scripts
|
|
259
|
-
|
|
291
|
+
# Gate: refuse to install a release whose workflow scripts fail the
|
|
292
|
+
# parse or size checks. _validate_workflows returns 10 on a parse
|
|
293
|
+
# failure, 20 on a size-budget failure; the rejection names the check
|
|
294
|
+
# that failed so the message is never misleading.
|
|
295
|
+
_vw_status=0
|
|
296
|
+
_validate_workflows "$staging_dir" || _vw_status=$?
|
|
297
|
+
if [ "$_vw_status" -ne 0 ]; then
|
|
260
298
|
rm -rf "$staging_dir"
|
|
261
|
-
|
|
299
|
+
case "$_vw_status" in
|
|
300
|
+
10) die "release $hash rejected: workflow parse check failed" ;;
|
|
301
|
+
20) die "release $hash rejected: workflow size-budget check failed" ;;
|
|
302
|
+
*) die "release $hash rejected: workflow validation failed (unexpected exit $_vw_status)" ;;
|
|
303
|
+
esac
|
|
262
304
|
fi
|
|
263
305
|
# Atomic rename into place
|
|
264
306
|
mv "$staging_dir" "$release_dir"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// publish-content.js — shared deterministic content-judgment primitives.
|
|
2
|
+
//
|
|
3
|
+
// Parent publish verification (lib/verify-publish.js) and unknown-recovery
|
|
4
|
+
// classification (lib/classify-publish-absence.js) both judge the same
|
|
5
|
+
// question — "does the on-disk source contain the publish commit's diff?" —
|
|
6
|
+
// against the same diff parser, findings parser, and collision-exemption
|
|
7
|
+
// rules. Two copies of the collision logic already diverged once (2026-09-15,
|
|
8
|
+
// task 00bca4b8); this module is the single source of truth.
|
|
9
|
+
//
|
|
10
|
+
// ESM (imported by verify-publish.js and classify-publish-absence.js).
|
|
11
|
+
// Pure and deterministic: no wall-clock reads, no randomness, no I/O except
|
|
12
|
+
// the git subprocesses in makeOldCounter (read-only).
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
17
|
+
|
|
18
|
+
// Parse `git diff base commit` into per-file added/removed lines.
|
|
19
|
+
// Byte-identical split to build-readback-request.js and the old
|
|
20
|
+
// verify-publish.js inline parser (the verifier looks raw lines up
|
|
21
|
+
// verbatim, so all consumers must agree on the split).
|
|
22
|
+
export function parseDiff(diffText) {
|
|
23
|
+
const expected = new Map(); // path -> { added: [], removed: [], isBinary: bool }
|
|
24
|
+
let curFile = null;
|
|
25
|
+
for (const line of diffText.split("\n")) {
|
|
26
|
+
if (line.startsWith("diff --git")) {
|
|
27
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
28
|
+
curFile = m ? m[2] : "unknown";
|
|
29
|
+
expected.set(curFile, { added: [], removed: [], isBinary: false });
|
|
30
|
+
} else if (curFile && line.startsWith("Binary files ")) {
|
|
31
|
+
expected.get(curFile).isBinary = true;
|
|
32
|
+
} else if (curFile && line.startsWith("+") && !line.startsWith("+++")) {
|
|
33
|
+
expected.get(curFile).added.push(line.slice(1));
|
|
34
|
+
} else if (curFile && line.startsWith("-") && !line.startsWith("---")) {
|
|
35
|
+
expected.get(curFile).removed.push(line.slice(1));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return expected;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parse the machine-readable findings block:
|
|
42
|
+
// FILE: <path>
|
|
43
|
+
// ADDED: <line> :: PRESENT|ABSENT
|
|
44
|
+
// REMOVED: <line> :: PRESENT|ABSENT
|
|
45
|
+
// END_FILE
|
|
46
|
+
export function parseFindings(text) {
|
|
47
|
+
const findings = new Map(); // path -> { added: Map(line->verdict), removed: Map(line->verdict) }
|
|
48
|
+
let cur = null;
|
|
49
|
+
let malformed = null;
|
|
50
|
+
for (const rawLine of text.split("\n")) {
|
|
51
|
+
const line = rawLine.trimEnd();
|
|
52
|
+
if (line.startsWith("FILE: ")) {
|
|
53
|
+
cur = { added: new Map(), removed: new Map() };
|
|
54
|
+
findings.set(line.slice(6).trim(), cur);
|
|
55
|
+
} else if (line === "END_FILE") {
|
|
56
|
+
cur = null;
|
|
57
|
+
} else if (cur && (line.startsWith("ADDED: ") || line.startsWith("REMOVED: "))) {
|
|
58
|
+
const kind = line.startsWith("ADDED: ") ? "added" : "removed";
|
|
59
|
+
const rest = line.slice(kind === "added" ? 7 : 9);
|
|
60
|
+
const sep = rest.lastIndexOf(" :: ");
|
|
61
|
+
if (sep < 0) { malformed = `malformed finding line: ${line.slice(0, 80)}`; break; }
|
|
62
|
+
const content = rest.slice(0, sep);
|
|
63
|
+
const verdict = rest.slice(sep + 4).trim();
|
|
64
|
+
if (verdict !== "PRESENT" && verdict !== "ABSENT") {
|
|
65
|
+
malformed = `bad verdict: ${verdict}`;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
cur[kind].set(content, verdict);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { findings, malformed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Whole-line occurrence counts of a file's content at <base>.
|
|
75
|
+
// [] when the base is the empty tree or the file did not exist there.
|
|
76
|
+
function oldTreeLines(repoPath, base, path) {
|
|
77
|
+
if (base === EMPTY_TREE) return [];
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
text = execFileSync("git", ["-C", repoPath, "show", `${base}:${path}`], {
|
|
81
|
+
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
if (text === "") return [];
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
if (lines[lines.length - 1] === "") lines.pop(); // drop the trailing-newline artifact
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Returns (path, line) => occurrences of line in path's old tree.
|
|
93
|
+
// Cached per path. Read-only git subprocesses.
|
|
94
|
+
export function makeOldCounter(repoPath, base) {
|
|
95
|
+
const cache = new Map(); // path -> Map(line -> count)
|
|
96
|
+
return (path, line) => {
|
|
97
|
+
let counts = cache.get(path);
|
|
98
|
+
if (counts === undefined) {
|
|
99
|
+
counts = new Map();
|
|
100
|
+
for (const l of oldTreeLines(repoPath, base, path)) {
|
|
101
|
+
counts.set(l, (counts.get(l) || 0) + 1);
|
|
102
|
+
}
|
|
103
|
+
cache.set(path, counts);
|
|
104
|
+
}
|
|
105
|
+
return counts.get(line) || 0;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Split each file's diff lines into discriminating vs non-discriminating.
|
|
110
|
+
//
|
|
111
|
+
// A line has zero discriminating power when its presence/absence cannot
|
|
112
|
+
// tell "hunk landed" from "hunk dropped":
|
|
113
|
+
// - added line already in the old tree (colliding pre-existing line):
|
|
114
|
+
// PRESENT whether or not the hunk landed.
|
|
115
|
+
// - removed line occurring in the old tree strictly more times than the
|
|
116
|
+
// diff removes it: PRESENT whether or not the removal happened.
|
|
117
|
+
//
|
|
118
|
+
// Returns Map(path -> {
|
|
119
|
+
// addedDisc: [...], removedDisc: [...], // discriminating lines
|
|
120
|
+
// unjudgeable: null | "binary-or-mode-or-rename" | "fully-colliding-added"
|
|
121
|
+
// }).
|
|
122
|
+
// unjudgeable marks files the line-based judge cannot certify at all:
|
|
123
|
+
// a diff with no content lines (binary, mode-only, rename), or added
|
|
124
|
+
// lines that ALL collide (their PRESENT findings prove nothing).
|
|
125
|
+
export function discriminatingLines(diffMap, oldCount) {
|
|
126
|
+
const out = new Map();
|
|
127
|
+
for (const [path, exp] of diffMap) {
|
|
128
|
+
const entry = { addedDisc: [], removedDisc: [], unjudgeable: null };
|
|
129
|
+
if (exp.added.length === 0 && exp.removed.length === 0) {
|
|
130
|
+
entry.unjudgeable = "binary-or-mode-or-rename";
|
|
131
|
+
out.set(path, entry);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const line of exp.added) {
|
|
135
|
+
if (oldCount(path, line) === 0) entry.addedDisc.push(line);
|
|
136
|
+
}
|
|
137
|
+
// The diff may remove the same line more than once; the old tree must
|
|
138
|
+
// account for every removal before a line counts as colliding.
|
|
139
|
+
const removedBudget = new Map();
|
|
140
|
+
for (const line of exp.removed) {
|
|
141
|
+
removedBudget.set(line, (removedBudget.get(line) || 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
for (const line of exp.removed) {
|
|
144
|
+
if (oldCount(path, line) <= removedBudget.get(line)) {
|
|
145
|
+
entry.removedDisc.push(line);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (exp.added.length > 0 && entry.addedDisc.length === 0) {
|
|
149
|
+
entry.unjudgeable = "fully-colliding-added";
|
|
150
|
+
}
|
|
151
|
+
out.set(path, entry);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
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 } 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
|
+
const crewHome = arg("--crew-home");
|
|
54
|
+
const taskId = arg("--task-id");
|
|
55
|
+
const phase = arg("--phase");
|
|
56
|
+
|
|
57
|
+
if (!["prepare", "complete"].includes(phase)) {
|
|
58
|
+
console.error("phase must be 'prepare' or 'complete'");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Shared helpers ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function nowISO() { return new Date().toISOString(); }
|
|
65
|
+
|
|
66
|
+
function writePublishNote(db, taskId, message) {
|
|
67
|
+
db.prepare(
|
|
68
|
+
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
69
|
+
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
70
|
+
).run(uuid(), taskId, message, nowISO());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Load the crew DB. We use better-sqlite3 via the crew-api's own dependency
|
|
74
|
+
// resolution — fall back to a clear error if unavailable.
|
|
75
|
+
let Database;
|
|
76
|
+
try {
|
|
77
|
+
Database = (await import("better-sqlite3")).default;
|
|
78
|
+
} catch {
|
|
79
|
+
fail("db-unavailable", "better-sqlite3 is not available to retry-publish.js");
|
|
80
|
+
}
|
|
81
|
+
const dbPath = join(crewHome, "crew.db");
|
|
82
|
+
if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
|
|
83
|
+
const db = new Database(dbPath);
|
|
84
|
+
|
|
85
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
86
|
+
|
|
87
|
+
function runMergeLock(action) {
|
|
88
|
+
const lockScript = join(crewHome, "lib", "merge-lock.sh");
|
|
89
|
+
try {
|
|
90
|
+
execFileSync(lockScript, [action, taskId], {
|
|
91
|
+
env: { ...process.env, CREW_HOME: crewHome },
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
timeout: 30000,
|
|
94
|
+
});
|
|
95
|
+
return { ok: true };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Phase 1: prepare --------------------------------------------------------
|
|
102
|
+
// Acquires the lock, re-checks HEAD, re-classifies, journals intent,
|
|
103
|
+
// generates the diff. On success the lock is HELD and the output carries
|
|
104
|
+
// the diff path/sha for the agent's artifact_edit child.
|
|
105
|
+
|
|
106
|
+
if (phase === "prepare") {
|
|
107
|
+
// Derive the unknown attempt (same derivation as scan-publish-unknown).
|
|
108
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
109
|
+
if (!task) fail("unknown-task", `task ${taskId} not found`);
|
|
110
|
+
if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
|
|
111
|
+
|
|
112
|
+
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
113
|
+
const slug = project && project.deploy_slug;
|
|
114
|
+
if (!slug) fail("no-slug", "project has no deploy_slug");
|
|
115
|
+
const repoPath = (project && project.repo_path) || null;
|
|
116
|
+
|
|
117
|
+
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
118
|
+
let entries = [];
|
|
119
|
+
try {
|
|
120
|
+
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
121
|
+
.filter((l) => l.trim().length > 0)
|
|
122
|
+
.map((l) => JSON.parse(l))
|
|
123
|
+
.filter((e) => e.task_id === taskId);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
const unknownEntry = entries[entries.length - 1];
|
|
128
|
+
if (!unknownEntry || unknownEntry.outcome !== "unknown") {
|
|
129
|
+
fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
|
|
130
|
+
}
|
|
131
|
+
const commit = unknownEntry.commit;
|
|
132
|
+
const attempt = unknownEntry.attempt || null;
|
|
133
|
+
if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
134
|
+
fail("bad-commit", "unknown ledger entry carries no usable commit hash");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 1. Re-check HEAD: must still be the retry target.
|
|
138
|
+
let head;
|
|
139
|
+
try {
|
|
140
|
+
head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
141
|
+
} catch (e) {
|
|
142
|
+
fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
if (head !== commit) {
|
|
145
|
+
writePublishNote(db, taskId,
|
|
146
|
+
`publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
|
|
147
|
+
process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Acquire the merge lock.
|
|
152
|
+
const lock = runMergeLock("acquire");
|
|
153
|
+
if (!lock.ok) {
|
|
154
|
+
process.stdout.write(JSON.stringify({
|
|
155
|
+
ok: true, phase: "prepare", outcome: "lock-held",
|
|
156
|
+
message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
|
|
157
|
+
}) + "\n");
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// From here to the end of phase 1, the lock is held. Any failure must
|
|
162
|
+
// release it — try/finally.
|
|
163
|
+
let lockHeld = true;
|
|
164
|
+
const releaseLock = () => {
|
|
165
|
+
if (lockHeld) {
|
|
166
|
+
runMergeLock("release");
|
|
167
|
+
lockHeld = false;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// 3. Inside the lock, re-classify.
|
|
173
|
+
let base = EMPTY_TREE;
|
|
174
|
+
try {
|
|
175
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
176
|
+
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
releaseLock();
|
|
179
|
+
fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
|
|
183
|
+
// Find the trigger entry for the manifest baseline.
|
|
184
|
+
const trigger = [...entries].reverse().find((e) =>
|
|
185
|
+
e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
|
|
186
|
+
const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
|
|
187
|
+
|
|
188
|
+
// Find the park note timestamp for quiesce calculation.
|
|
189
|
+
const parkNote = db.prepare(
|
|
190
|
+
`SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
|
|
191
|
+
AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
|
|
192
|
+
ORDER BY timestamp DESC LIMIT 1`
|
|
193
|
+
).get(taskId);
|
|
194
|
+
if (!parkNote) {
|
|
195
|
+
releaseLock();
|
|
196
|
+
fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const classifierArgs = [
|
|
200
|
+
classifierPath,
|
|
201
|
+
"--slug", slug,
|
|
202
|
+
"--commit", commit,
|
|
203
|
+
"--base", base,
|
|
204
|
+
"--trigger-ts", trigger ? trigger.ts : parkNote.timestamp,
|
|
205
|
+
"--park-ts", parkNote.timestamp,
|
|
206
|
+
"--repo-path", repoPath,
|
|
207
|
+
];
|
|
208
|
+
if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
|
|
209
|
+
|
|
210
|
+
let classification;
|
|
211
|
+
try {
|
|
212
|
+
const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
|
213
|
+
classification = JSON.parse(out);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
releaseLock();
|
|
216
|
+
fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!classification.ok || classification.decision !== "provably-dropped") {
|
|
220
|
+
// Not dropped — release the lock and route via the existing
|
|
221
|
+
// record-retry-recheck path (the code owns the routing).
|
|
222
|
+
releaseLock();
|
|
223
|
+
process.stdout.write(JSON.stringify({
|
|
224
|
+
ok: true, phase: "prepare", outcome: "not-dropped",
|
|
225
|
+
decision: classification.decision,
|
|
226
|
+
message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
|
|
227
|
+
classification,
|
|
228
|
+
}) + "\n");
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3b. Capture the pre-retry manifest baseline (inside the lock).
|
|
233
|
+
let retryBaseline = null;
|
|
234
|
+
try {
|
|
235
|
+
const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
|
|
236
|
+
if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
|
|
237
|
+
retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
|
|
238
|
+
}
|
|
239
|
+
} catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
|
|
240
|
+
|
|
241
|
+
// 4. Journal the intent BEFORE the trigger.
|
|
242
|
+
writePublishNote(db, taskId,
|
|
243
|
+
`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.`);
|
|
244
|
+
|
|
245
|
+
// 5. Regenerate the diff deterministically.
|
|
246
|
+
const diffPath = `/tmp/retry-diff-${taskId}.txt`;
|
|
247
|
+
let diffMeta;
|
|
248
|
+
try {
|
|
249
|
+
const out = execFileSync("node", [
|
|
250
|
+
join(crewHome, "lib", "compute-publish-diff.js"),
|
|
251
|
+
"--repo-path", repoPath,
|
|
252
|
+
"--base", base,
|
|
253
|
+
"--out", diffPath,
|
|
254
|
+
], { encoding: "utf8" });
|
|
255
|
+
diffMeta = JSON.parse(out);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
releaseLock();
|
|
258
|
+
fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
|
|
259
|
+
}
|
|
260
|
+
if (!diffMeta.ok) {
|
|
261
|
+
releaseLock();
|
|
262
|
+
fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Success: lock remains HELD. The agent spawns the artifact_edit child
|
|
266
|
+
// with the diff, then calls phase 2 (complete) to finish.
|
|
267
|
+
// Persist the phase-1 state for phase 2 (crash recovery reads the notes,
|
|
268
|
+
// but phase 2 needs the exact commit/attempt/baseline/ledger path).
|
|
269
|
+
const statePath = `/tmp/retry-publish-${taskId}.json`;
|
|
270
|
+
writeFileSync(statePath, JSON.stringify({
|
|
271
|
+
task_id: taskId, commit, attempt, slug, repoPath,
|
|
272
|
+
project_id: project.id, ledger_path: ledgerPath,
|
|
273
|
+
retry_baseline: retryBaseline,
|
|
274
|
+
diff_path: diffPath, diff_sha256: diffMeta.sha256,
|
|
275
|
+
prepared_at: nowISO(),
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
process.stdout.write(JSON.stringify({
|
|
279
|
+
ok: true, phase: "prepare", outcome: "ready",
|
|
280
|
+
task_id: taskId, commit, attempt,
|
|
281
|
+
diff_path: diffPath, diff_sha256: diffMeta.sha256,
|
|
282
|
+
slug,
|
|
283
|
+
message: "lock held; spawn the artifact_edit child with the diff, then call phase 2 (complete)",
|
|
284
|
+
}) + "\n");
|
|
285
|
+
process.exit(0);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
releaseLock();
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Phase 2: complete -------------------------------------------------------
|
|
293
|
+
// Given the artifact_edit outcome, write the outcome notes, append the
|
|
294
|
+
// ledger entry, mirror verification, and RELEASE the lock (finally).
|
|
295
|
+
|
|
296
|
+
if (phase === "complete") {
|
|
297
|
+
const editOutcome = arg("--edit-outcome");
|
|
298
|
+
if (!["accepted", "refused"].includes(editOutcome)) {
|
|
299
|
+
console.error("--edit-outcome must be 'accepted' or 'refused'");
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
const refusalText = arg("--refusal-text", false, "");
|
|
303
|
+
|
|
304
|
+
const statePath = `/tmp/retry-publish-${taskId}.json`;
|
|
305
|
+
if (!existsSync(statePath)) {
|
|
306
|
+
fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
|
|
307
|
+
}
|
|
308
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
309
|
+
const { commit, attempt, slug, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
|
|
310
|
+
|
|
311
|
+
let lockHeld = true;
|
|
312
|
+
const releaseLock = () => {
|
|
313
|
+
if (lockHeld) {
|
|
314
|
+
runMergeLock("release");
|
|
315
|
+
lockHeld = false;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
if (editOutcome === "refused") {
|
|
321
|
+
// Conclusive negative: rejected, not unknown. Terminal.
|
|
322
|
+
writePublishNote(db, taskId,
|
|
323
|
+
`publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
|
|
324
|
+
process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The child ended after the edit call with no explicit refusal —
|
|
329
|
+
// fire-and-forget, same shape as the first attempt.
|
|
330
|
+
writePublishNote(db, taskId,
|
|
331
|
+
`publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
|
|
332
|
+
|
|
333
|
+
// Mirror verification with a settle window.
|
|
334
|
+
const notBefore = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
335
|
+
const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
|
|
336
|
+
writePublishNote(db, taskId,
|
|
337
|
+
`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.`);
|
|
338
|
+
|
|
339
|
+
// Append the -retry1 ledger entry with the pre-retry baseline (required).
|
|
340
|
+
const ledgerEntry = {
|
|
341
|
+
ts: nowISO(),
|
|
342
|
+
task_id: taskId,
|
|
343
|
+
workflow: "unknown-recovery",
|
|
344
|
+
slug,
|
|
345
|
+
commit,
|
|
346
|
+
attempt: retryAttempt,
|
|
347
|
+
agent_id: null,
|
|
348
|
+
applied_report: null,
|
|
349
|
+
outcome: "submitted",
|
|
350
|
+
manifest_before: retryBaseline,
|
|
351
|
+
detail: "retry trigger re-issued after provably-dropped classification",
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// Best-effort: the note journal is the source of truth, not the ledger.
|
|
357
|
+
console.error(`ledger append failed (non-fatal): ${e.message}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
process.stdout.write(JSON.stringify({
|
|
361
|
+
ok: true, phase: "complete", outcome: "issued",
|
|
362
|
+
commit, attempt: retryAttempt, not_before: notBefore,
|
|
363
|
+
}) + "\n");
|
|
364
|
+
} finally {
|
|
365
|
+
releaseLock();
|
|
366
|
+
// Clean up the phase-1 state file (best-effort).
|
|
367
|
+
try { (await import("fs")).unlinkSync(statePath); } catch {}
|
|
368
|
+
}
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|