muse-crew 0.14.5 → 0.14.7
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 +106 -12
- package/docs/decisions/AGENTS.md +5 -0
- package/docs/decisions/publish-path.md +247 -9
- package/docs/decisions/qa-reproduce.md +30 -0
- package/docs/guide.md +41 -5
- package/docs/publish-unknown-recovery.md +28 -120
- package/docs/publish-verification.md +148 -501
- package/lib/AGENTS.md +9 -13
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +82 -5
- package/lib/crew-api.js +1134 -1027
- package/lib/merge-lock.sh +153 -41
- package/lib/publish-note-vocabulary.js +135 -42
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +69 -0
- package/lib/serve-artifact.js +46 -2
- package/lib/test-detached-integrate.sh +122 -0
- package/lib/test-merge-lock.sh +30 -1
- package/lib/worktree-lifecycle.sh +284 -34
- 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 +55 -89
- package/seed/crons.json +12 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +489 -224
- package/workflows/chore.js +306 -226
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +494 -251
- package/workflows/upgrade.js +13 -1
- package/lib/build-readback-request.js +0 -140
- package/lib/check-intent-freshness.js +0 -101
- 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 -417
- package/lib/verify-publish.js +0 -416
package/lib/readback-disk.js
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// readback-disk.js — deterministic disk read-back sensor for parent publish
|
|
3
|
-
// verification (docs/publish-verification.md).
|
|
4
|
-
//
|
|
5
|
-
// The read-back gap (2026-09-14): the platform removed `artifact_inspect`
|
|
6
|
-
// and no agent-callable content read-back existed, so every artifact publish
|
|
7
|
-
// parked at `publish: verification-requested` permanently. The platform's
|
|
8
|
-
// artifact edits land in its on-disk working copy of the artifact source
|
|
9
|
-
// (~/workspace/ts-spaces/<slug>/ — verified empirically 2026-09-15: added
|
|
10
|
-
// lines present, removed lines absent across real platform commits), so a
|
|
11
|
-
// deterministic local sensor can replace the removed inspection tool: no
|
|
12
|
-
// LLM, no async handoff, no prose to parse.
|
|
13
|
-
//
|
|
14
|
-
// This script is the SENSOR; lib/verify-publish.js stays the judge. It
|
|
15
|
-
// emits the exact machine-readable findings block the verifier already
|
|
16
|
-
// parses:
|
|
17
|
-
//
|
|
18
|
-
// FILE: <path>
|
|
19
|
-
// ADDED: <exact added line from the diff> :: PRESENT|ABSENT
|
|
20
|
-
// REMOVED: <exact removed line from the diff> :: PRESENT|ABSENT
|
|
21
|
-
// END_FILE
|
|
22
|
-
//
|
|
23
|
-
// one ADDED line per (+) diff line and one REMOVED line per (-) diff line,
|
|
24
|
-
// in diff order. The verdict is PRESENT iff that exact line occurs in the
|
|
25
|
-
// file's CURRENT on-disk source (whole-line match), ABSENT otherwise.
|
|
26
|
-
//
|
|
27
|
-
// Authority boundary: the sensor reads the platform's working copy of the
|
|
28
|
-
// artifact source — the tree the hosted artifact is built/served from. If
|
|
29
|
-
// the working copy is stale relative to a just-applied edit, the findings
|
|
30
|
-
// honestly report ABSENT and the verifier fails CLOSED (parked, never a
|
|
31
|
-
// false stamp). Staleness can only park, never certify.
|
|
32
|
-
//
|
|
33
|
-
// Usage:
|
|
34
|
-
// node readback-disk.js --repo-path <path> --commit <sha> --base <sha>
|
|
35
|
-
// --slug <artifact-slug> --task-id <uuid> [--spaces-root <dir>]
|
|
36
|
-
//
|
|
37
|
-
// --base is the previously-stamped provenance source_commit (or the
|
|
38
|
-
// empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
|
|
39
|
-
// publish) — the SAME base the verifier uses. --spaces-root defaults to
|
|
40
|
-
// ~/workspace/ts-spaces.
|
|
41
|
-
//
|
|
42
|
-
// Exit codes: 0 ok (findings block on stdout) · 2 usage/validation ·
|
|
43
|
-
// 1 git/diff/read failure. On non-zero exit NOTHING is printed to stdout,
|
|
44
|
-
// so a caller must never save stdout as a findings file.
|
|
45
|
-
//
|
|
46
|
-
// Security: the slug is charset-validated and the resolved space directory
|
|
47
|
-
// must stay under the spaces root (realpath containment); every file path
|
|
48
|
-
// from the diff is likewise contained before reading. A path that escapes
|
|
49
|
-
// is a validation failure (exit 2), never a read.
|
|
50
|
-
|
|
51
|
-
import { execFileSync } from "node:child_process";
|
|
52
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
53
|
-
import { homedir } from "node:os";
|
|
54
|
-
import { join, resolve } from "node:path";
|
|
55
|
-
|
|
56
|
-
// --help: before required-arg parsing (shebang⇔CLI contract).
|
|
57
|
-
if (process.argv.slice(2).includes("--help")) {
|
|
58
|
-
console.log(
|
|
59
|
-
"usage: node readback-disk.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> [--spaces-root <dir>]\n" +
|
|
60
|
-
"the deterministic disk read-back SENSOR: emits the machine-readable findings block (FILE:/ADDED:/REMOVED:/END_FILE)"
|
|
61
|
-
);
|
|
62
|
-
process.exit(0);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
66
|
-
|
|
67
|
-
function arg(name) {
|
|
68
|
-
const i = process.argv.indexOf(name);
|
|
69
|
-
if (i < 0 || i + 1 >= process.argv.length) {
|
|
70
|
-
fail("usage", `${name} is required.`, 2);
|
|
71
|
-
}
|
|
72
|
-
return process.argv[i + 1];
|
|
73
|
-
}
|
|
74
|
-
function fail(code, message, exitCode) {
|
|
75
|
-
// stderr only — stdout must stay empty on failure.
|
|
76
|
-
process.stderr.write(JSON.stringify({ ok: false, error: code, message }) + "\n");
|
|
77
|
-
process.exit(exitCode);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const repoPath = arg("--repo-path");
|
|
81
|
-
const commit = arg("--commit");
|
|
82
|
-
const base = arg("--base");
|
|
83
|
-
const slug = arg("--slug");
|
|
84
|
-
const taskId = arg("--task-id");
|
|
85
|
-
const spacesRoot = process.argv.includes("--spaces-root")
|
|
86
|
-
? arg("--spaces-root")
|
|
87
|
-
: join(homedir(), "workspace", "ts-spaces");
|
|
88
|
-
|
|
89
|
-
if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
|
|
90
|
-
if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
|
|
91
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(slug)) {
|
|
92
|
-
fail("usage", `slug ${JSON.stringify(slug)} is not a safe artifact slug.`, 2);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Resolve the space directory with realpath containment: the slug must not
|
|
96
|
-
// escape the spaces root (no "..", no symlinks pointing out).
|
|
97
|
-
let spaceDir;
|
|
98
|
-
try {
|
|
99
|
-
const rootReal = realpathSync(spacesRoot);
|
|
100
|
-
const candReal = realpathSync(join(spacesRoot, slug));
|
|
101
|
-
if (candReal !== rootReal && !candReal.startsWith(rootReal + "/")) {
|
|
102
|
-
fail("usage", `slug ${JSON.stringify(slug)} resolves outside the spaces root.`, 2);
|
|
103
|
-
}
|
|
104
|
-
spaceDir = candReal;
|
|
105
|
-
} catch (e) {
|
|
106
|
-
fail("usage", `cannot resolve space dir for slug ${JSON.stringify(slug)}: ${e.message}`, 2);
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
if (!statSync(spaceDir).isDirectory()) fail("usage", `space dir ${spaceDir} is not a directory.`, 2);
|
|
110
|
-
} catch (e) {
|
|
111
|
-
fail("usage", `space dir ${spaceDir} unreadable: ${e.message}`, 2);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// The publish delta is base..commit — the same diff the verifier checks the
|
|
115
|
-
// findings against. Refuse a base that is not an ancestor: certifying a
|
|
116
|
-
// diff against an unrelated tree is worse than no read-back.
|
|
117
|
-
if (base !== EMPTY_TREE) {
|
|
118
|
-
try {
|
|
119
|
-
execFileSync("git", ["-C", repoPath, "merge-base", "--is-ancestor", base, commit], { stdio: "ignore" });
|
|
120
|
-
} catch (e) {
|
|
121
|
-
fail("usage", `base ${base} is not an ancestor of commit ${commit} — refusing to read back an unrelated tree.`, 2);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
let diff;
|
|
126
|
-
try {
|
|
127
|
-
diff = execFileSync("git", ["-C", repoPath, "diff", base, commit, "--"], {
|
|
128
|
-
encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
129
|
-
});
|
|
130
|
-
} catch (e) {
|
|
131
|
-
fail("git", `git diff failed: ${e.message}`, 1);
|
|
132
|
-
}
|
|
133
|
-
if (!diff.trim()) {
|
|
134
|
-
fail("git", "empty diff for commit — nothing to read back.", 1);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Parse the diff into per-file added/removed lines (byte-identical parser
|
|
138
|
-
// to build-readback-request.js and verify-publish.js: the verifier looks
|
|
139
|
-
// the raw lines up verbatim, so all three must agree on the split).
|
|
140
|
-
const files = [];
|
|
141
|
-
let current = null;
|
|
142
|
-
for (const line of diff.split("\n")) {
|
|
143
|
-
if (line.startsWith("diff --git")) {
|
|
144
|
-
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
145
|
-
current = { path: m ? m[2] : "unknown", added: [], removed: [] };
|
|
146
|
-
files.push(current);
|
|
147
|
-
} else if (current && line.startsWith("+") && !line.startsWith("+++")) {
|
|
148
|
-
current.added.push(line.slice(1));
|
|
149
|
-
} else if (current && line.startsWith("-") && !line.startsWith("---")) {
|
|
150
|
-
current.removed.push(line.slice(1));
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Whole-line content of a disk file, normalized for the membership test.
|
|
155
|
-
// Trailing-newline artifact popped (same convention as verify-publish.js
|
|
156
|
-
// oldTreeLines); one trailing CR stripped per line so CRLF working copies
|
|
157
|
-
// compare honestly against LF diffs. The RAW diff lines are still what get
|
|
158
|
-
// emitted — the verifier looks those up verbatim.
|
|
159
|
-
const norm = (s) => (s.endsWith("\r") ? s.slice(0, -1) : s);
|
|
160
|
-
function diskLines(absPath) {
|
|
161
|
-
if (!existsSync(absPath)) return new Set(); // missing file: every line ABSENT
|
|
162
|
-
let text;
|
|
163
|
-
try {
|
|
164
|
-
text = readFileSync(absPath, "utf8");
|
|
165
|
-
} catch (e) {
|
|
166
|
-
fail("read", `cannot read ${absPath}: ${e.message}`, 1);
|
|
167
|
-
}
|
|
168
|
-
const lines = text.split("\n");
|
|
169
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
170
|
-
return new Set(lines.map(norm));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const out = [];
|
|
174
|
-
for (const f of files) {
|
|
175
|
-
// Contain every diff path inside the space dir before reading.
|
|
176
|
-
const abs = resolve(spaceDir, f.path);
|
|
177
|
-
if (abs !== spaceDir && !abs.startsWith(spaceDir + "/")) {
|
|
178
|
-
fail("usage", `diff path ${JSON.stringify(f.path)} escapes the space dir.`, 2);
|
|
179
|
-
}
|
|
180
|
-
const present = diskLines(abs);
|
|
181
|
-
out.push(`FILE: ${f.path}`);
|
|
182
|
-
for (const line of f.added) {
|
|
183
|
-
out.push(`ADDED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
|
|
184
|
-
}
|
|
185
|
-
for (const line of f.removed) {
|
|
186
|
-
out.push(`REMOVED: ${line} :: ${present.has(norm(line)) ? "PRESENT" : "ABSENT"}`);
|
|
187
|
-
}
|
|
188
|
-
out.push("END_FILE");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
process.stdout.write(out.join("\n") + "\n");
|
|
192
|
-
process.stderr.write(JSON.stringify({
|
|
193
|
-
ok: true, slug, task_id: taskId, commit, base,
|
|
194
|
-
files: files.map((f) => f.path),
|
|
195
|
-
} ) + "\n");
|
package/lib/retry-publish.js
DELETED
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// retry-publish.js — Deterministic unknown-recovery retry protocol (2026-09-18, should-fix 5;
|
|
3
|
-
// ported 2026-09-20: node:sqlite + crew-state.db (blocker 23) and direct
|
|
4
|
-
// tick-worker issuance (blocker 22 — children cannot reach artifact_edit).
|
|
5
|
-
//
|
|
6
|
-
// The cron Step 4.4 retry protocol was prose-owned: the cron agent performed
|
|
7
|
-
// lock handling, journaling, diff generation, ledger writes, mirroring, and
|
|
8
|
-
// cleanup by following template instructions. A missed step (or a tick dying
|
|
9
|
-
// mid-protocol) left the lock held or the state inconsistent.
|
|
10
|
-
//
|
|
11
|
-
// This command owns all deterministic transitions with try/finally lock
|
|
12
|
-
// release. Only the unavoidable `artifact_edit` tool call remains
|
|
13
|
-
// tick-mediated (it requires the artifact namespace, which this process
|
|
14
|
-
// cannot load): the session-carrying tick worker issues the edit directly
|
|
15
|
-
// in its own turn — NEVER a child (blocker 22, room #24).
|
|
16
|
-
//
|
|
17
|
-
// Two phases:
|
|
18
|
-
// Phase 1 (prepare): acquire merge lock, re-check HEAD, re-classify,
|
|
19
|
-
// journal intent, generate diff. Outputs JSON with the diff path/sha
|
|
20
|
-
// for the tick worker's direct artifact_edit call. The lock is HELD on
|
|
21
|
-
// success (the tick must call phase 2 to release it).
|
|
22
|
-
// Phase 2 (complete): given the artifact_edit outcome (accepted/refused),
|
|
23
|
-
// write the outcome notes, append the ledger entry, mirror verification,
|
|
24
|
-
// and RELEASE the lock in a finally block.
|
|
25
|
-
//
|
|
26
|
-
// Crash safety: if the tick dies between phases, the lock expires after
|
|
27
|
-
// 600s and the note journal (`publish: retry-intended` without
|
|
28
|
-
// `publish: retry-issued`) lets the next scan mirror verification-requested
|
|
29
|
-
// without re-triggering — exactly one retry, enforced from notes.
|
|
30
|
-
//
|
|
31
|
-
// Usage:
|
|
32
|
-
// node retry-publish.js --crew-home <path> --task-id <uuid> --phase prepare
|
|
33
|
-
// node retry-publish.js --crew-home <path> --task-id <uuid> --phase complete --edit-outcome <accepted|refused> [--refusal-text <text>]
|
|
34
|
-
//
|
|
35
|
-
// Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
|
|
36
|
-
|
|
37
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
38
|
-
import { join } from "path";
|
|
39
|
-
import { execFileSync } from "child_process";
|
|
40
|
-
import { randomUUID as uuid } from "crypto";
|
|
41
|
-
|
|
42
|
-
const args = process.argv.slice(2);
|
|
43
|
-
function arg(name, required = true, def = undefined) {
|
|
44
|
-
const i = args.indexOf(name);
|
|
45
|
-
if (i === -1 || i + 1 >= args.length) {
|
|
46
|
-
if (required) { console.error(`missing required arg ${name}`); process.exit(2); }
|
|
47
|
-
return def;
|
|
48
|
-
}
|
|
49
|
-
return args[i + 1];
|
|
50
|
-
}
|
|
51
|
-
function fail(code, msg, exitCode = 1) {
|
|
52
|
-
process.stdout.write(JSON.stringify({ ok: false, code, message: msg }) + "\n");
|
|
53
|
-
process.exit(exitCode);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// --help: handled before any required-arg parsing so
|
|
57
|
-
// `node retry-publish.js --help` always exits 0 (entry-gate contract).
|
|
58
|
-
if (args.includes("--help")) {
|
|
59
|
-
console.log(
|
|
60
|
-
"usage: node retry-publish.js --crew-home <path> --task-id <uuid> --phase <prepare|complete>\n" +
|
|
61
|
-
" prepare: acquire the merge lock, re-check HEAD, re-classify, journal intent, emit the diff\n" +
|
|
62
|
-
" complete: --edit-outcome <accepted|refused> [--refusal-text <text>] — write the outcome,\n" +
|
|
63
|
-
" append the ledger entry, mirror verification, and release the lock\n" +
|
|
64
|
-
"exit 0 = success, 1 = terminal stop, 2 = usage error"
|
|
65
|
-
);
|
|
66
|
-
process.exit(0);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const crewHome = arg("--crew-home");
|
|
70
|
-
const taskId = arg("--task-id");
|
|
71
|
-
const phase = arg("--phase");
|
|
72
|
-
|
|
73
|
-
if (!["prepare", "complete"].includes(phase)) {
|
|
74
|
-
console.error("phase must be 'prepare' or 'complete'");
|
|
75
|
-
process.exit(2);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Retry protocol: phase-1 state is a persistent-disk pin
|
|
79
|
-
// ($CREW_HOME/.pins/ — never /tmp: tmpfs is wiped on cell reboot, and the
|
|
80
|
-
// lock release depends on phase 2 reading this state).
|
|
81
|
-
function pinsDir() {
|
|
82
|
-
const dir = join(crewHome, ".pins");
|
|
83
|
-
mkdirSync(dir, { recursive: true });
|
|
84
|
-
return dir;
|
|
85
|
-
}
|
|
86
|
-
function retryStatePath() {
|
|
87
|
-
return join(pinsDir(), `retry-publish-${taskId}.json`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// --- Shared helpers ---------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
function nowISO() { return new Date().toISOString(); }
|
|
93
|
-
|
|
94
|
-
function writePublishNote(db, taskId, message) {
|
|
95
|
-
db.prepare(
|
|
96
|
-
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
97
|
-
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
98
|
-
).run(uuid(), taskId, message, nowISO());
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Load the crew DB via node:sqlite (the runtime's built-in — 2026-09-20,
|
|
102
|
-
// blocker 23: better-sqlite3 is not available and the old code pointed at
|
|
103
|
-
// the wrong database name). crew-api.js opens the same path the same way.
|
|
104
|
-
import { DatabaseSync } from "node:sqlite";
|
|
105
|
-
const dbPath = join(crewHome, "crew-state.db");
|
|
106
|
-
if (!existsSync(dbPath)) fail("no-db", `crew database not found at ${dbPath}`);
|
|
107
|
-
const db = new DatabaseSync(dbPath);
|
|
108
|
-
|
|
109
|
-
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
110
|
-
|
|
111
|
-
// The merge lock is per-repo (merge-lock.sh fails closed without
|
|
112
|
-
// CREW_REPO — 2026-09-20: the old code never set it, so the lock could
|
|
113
|
-
// never be acquired). The retry protocol locks the task's own repo.
|
|
114
|
-
function runMergeLock(action, repoPath) {
|
|
115
|
-
const lockScript = join(crewHome, "lib", "merge-lock.sh");
|
|
116
|
-
try {
|
|
117
|
-
execFileSync(lockScript, [action, taskId], {
|
|
118
|
-
env: { ...process.env, CREW_HOME: crewHome, CREW_REPO: repoPath },
|
|
119
|
-
encoding: "utf8",
|
|
120
|
-
timeout: 30000,
|
|
121
|
-
});
|
|
122
|
-
return { ok: true };
|
|
123
|
-
} catch (e) {
|
|
124
|
-
return { ok: false, error: (e.stderr || e.message || "").slice(0, 300) };
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// --- Phase 1: prepare --------------------------------------------------------
|
|
129
|
-
// Acquires the lock, re-checks HEAD, re-classifies, journals intent,
|
|
130
|
-
// generates the diff. On success the lock is HELD and the output carries
|
|
131
|
-
// the diff path/sha for the tick worker's direct artifact_edit call.
|
|
132
|
-
|
|
133
|
-
if (phase === "prepare") {
|
|
134
|
-
// Derive the unknown attempt (same derivation as scan-publish-unknown).
|
|
135
|
-
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
136
|
-
if (!task) fail("unknown-task", `task ${taskId} not found`);
|
|
137
|
-
if (task.state !== "parked") fail("not-parked", `task is not parked (state=${task.state})`);
|
|
138
|
-
|
|
139
|
-
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
140
|
-
const slug = project && project.deploy_slug;
|
|
141
|
-
if (!slug) fail("no-slug", "project has no deploy_slug");
|
|
142
|
-
const repoPath = (project && project.repo_path) || null;
|
|
143
|
-
if (!repoPath) fail("no-repo", "project has no repo_path — the retry protocol locks the task's repo");
|
|
144
|
-
|
|
145
|
-
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
146
|
-
let entries = [];
|
|
147
|
-
try {
|
|
148
|
-
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
149
|
-
.filter((l) => l.trim().length > 0)
|
|
150
|
-
.map((l) => JSON.parse(l))
|
|
151
|
-
.filter((e) => e.task_id === taskId);
|
|
152
|
-
} catch (e) {
|
|
153
|
-
fail("ledger-unreadable", `cannot read publish ledger: ${e.message}`);
|
|
154
|
-
}
|
|
155
|
-
const unknownEntry = entries[entries.length - 1];
|
|
156
|
-
if (!unknownEntry || unknownEntry.outcome !== "unknown") {
|
|
157
|
-
fail("not-unknown", "latest ledger entry is not unknown — nothing to retry");
|
|
158
|
-
}
|
|
159
|
-
const commit = unknownEntry.commit;
|
|
160
|
-
const attempt = unknownEntry.attempt || null;
|
|
161
|
-
if (!commit || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
162
|
-
fail("bad-commit", "unknown ledger entry carries no usable commit hash");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 1. Re-check HEAD: must still be the retry target.
|
|
166
|
-
let head;
|
|
167
|
-
try {
|
|
168
|
-
head = execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
169
|
-
} catch (e) {
|
|
170
|
-
fail("head-unreadable", `git rev-parse HEAD failed: ${e.message}`);
|
|
171
|
-
}
|
|
172
|
-
if (head !== commit) {
|
|
173
|
-
writePublishNote(db, taskId,
|
|
174
|
-
`publish: retry-superseded ${commit} — HEAD is ${head}; the retry target moved. Recovery aborted.`);
|
|
175
|
-
process.stdout.write(JSON.stringify({ ok: true, phase: "prepare", outcome: "superseded", commit, head }) + "\n");
|
|
176
|
-
process.exit(0);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 2. Acquire the merge lock.
|
|
180
|
-
const lock = runMergeLock("acquire", repoPath);
|
|
181
|
-
if (!lock.ok) {
|
|
182
|
-
process.stdout.write(JSON.stringify({
|
|
183
|
-
ok: true, phase: "prepare", outcome: "lock-held",
|
|
184
|
-
message: `merge lock held for ${taskId}; the drop note is still latest, so the next tick retries`,
|
|
185
|
-
}) + "\n");
|
|
186
|
-
process.exit(0);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// From here to the end of phase 1, the lock is held. Any failure must
|
|
190
|
-
// release it — try/finally.
|
|
191
|
-
let lockHeld = true;
|
|
192
|
-
const releaseLock = () => {
|
|
193
|
-
if (lockHeld) {
|
|
194
|
-
runMergeLock("release", repoPath);
|
|
195
|
-
lockHeld = false;
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
try {
|
|
200
|
-
// 3. Inside the lock, re-classify.
|
|
201
|
-
let base = EMPTY_TREE;
|
|
202
|
-
try {
|
|
203
|
-
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
204
|
-
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) base = prov.provenance_source_commit;
|
|
205
|
-
} catch (e) {
|
|
206
|
-
releaseLock();
|
|
207
|
-
fail("provenance-query-failed", `provenance lookup failed: ${e.message} — failing closed`);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
|
|
211
|
-
// Find the trigger entry for the manifest baseline: the OLDEST
|
|
212
|
-
// tick-worker "submitted" entry by ts (D1, 2026-09-19; entry_kind cut
|
|
213
|
-
// 2026-09-20; issuer anchor 2026-09-20, one-party worker-owned publish —
|
|
214
|
-
// only the session-carrying tick worker performs issuance, so only its
|
|
215
|
-
// stamped writes bind; a workflow-written "submitted" without issuer is
|
|
216
|
-
// a phantom claim, never a baseline source) — it binds issued_at
|
|
217
|
-
// (falling back to its ts when the capture was unobserved), the same
|
|
218
|
-
// anchor verify-publish.js and findTriggerEntry use.
|
|
219
|
-
// agent_id is null on the issuance entry (no receipt observed at trigger
|
|
220
|
-
// time) — the baseline comes from manifest_before, never agent_id.
|
|
221
|
-
const submitted = entries.filter((e) =>
|
|
222
|
-
e.outcome === "submitted" && e.issuer === "tick-worker" &&
|
|
223
|
-
e.commit === commit && (attempt == null || e.attempt === attempt));
|
|
224
|
-
submitted.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
225
|
-
const trigger = submitted[0] || null;
|
|
226
|
-
const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
|
|
227
|
-
|
|
228
|
-
// Find the park note timestamp for quiesce calculation.
|
|
229
|
-
const parkNote = db.prepare(
|
|
230
|
-
`SELECT message, timestamp FROM events WHERE task_id = ? AND type = 'note'
|
|
231
|
-
AND (message LIKE '%publish: dropped%' OR message LIKE '%publish outcome unknown%')
|
|
232
|
-
ORDER BY timestamp DESC LIMIT 1`
|
|
233
|
-
).get(taskId);
|
|
234
|
-
if (!parkNote) {
|
|
235
|
-
releaseLock();
|
|
236
|
-
fail("no-park-note", "no dropped/unknown park note found — cannot determine quiesce window");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const classifierArgs = [
|
|
240
|
-
classifierPath,
|
|
241
|
-
"--slug", slug,
|
|
242
|
-
"--commit", commit,
|
|
243
|
-
"--base", base,
|
|
244
|
-
"--trigger-ts", trigger ? (trigger.issued_at || trigger.ts) : parkNote.timestamp,
|
|
245
|
-
"--park-ts", parkNote.timestamp,
|
|
246
|
-
"--repo-path", repoPath,
|
|
247
|
-
];
|
|
248
|
-
if (manifestBefore) classifierArgs.push("--manifest-before", manifestBefore);
|
|
249
|
-
|
|
250
|
-
let classification;
|
|
251
|
-
try {
|
|
252
|
-
const out = execFileSync("node", classifierArgs, { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
|
253
|
-
classification = JSON.parse(out);
|
|
254
|
-
} catch (e) {
|
|
255
|
-
releaseLock();
|
|
256
|
-
fail("classifier-failed", `classify-publish-absence.js failed: ${(e.stderr || e.message).slice(0, 300)}`);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (!classification.ok || classification.decision !== "provably-dropped") {
|
|
260
|
-
// Not dropped — release the lock and route via the existing
|
|
261
|
-
// record-retry-recheck path (the code owns the routing).
|
|
262
|
-
releaseLock();
|
|
263
|
-
process.stdout.write(JSON.stringify({
|
|
264
|
-
ok: true, phase: "prepare", outcome: "not-dropped",
|
|
265
|
-
decision: classification.decision,
|
|
266
|
-
message: "re-classification is not provably-dropped; the tick routes via record-retry-recheck",
|
|
267
|
-
classification,
|
|
268
|
-
}) + "\n");
|
|
269
|
-
process.exit(0);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// 3b. Capture the pre-retry manifest baseline (inside the lock).
|
|
273
|
-
let retryBaseline = null;
|
|
274
|
-
try {
|
|
275
|
-
const m = JSON.parse(readFileSync(join(process.env.HOME, "workspace", "ts-spaces", slug, ".space-build", "manifest.json"), "utf8"));
|
|
276
|
-
if (m && typeof m.built_at === "string" && typeof m.content_sha256 === "string") {
|
|
277
|
-
retryBaseline = { built_at: m.built_at, content_sha256: m.content_sha256 };
|
|
278
|
-
}
|
|
279
|
-
} catch { /* missing/unparsable → null; the verifier fails closed without a baseline */ }
|
|
280
|
-
|
|
281
|
-
// 4. Journal the intent BEFORE the trigger.
|
|
282
|
-
writePublishNote(db, taskId,
|
|
283
|
-
`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.`);
|
|
284
|
-
|
|
285
|
-
// 5. Regenerate the diff deterministically.
|
|
286
|
-
const diffPath = `/tmp/retry-diff-${taskId}.txt`;
|
|
287
|
-
let diffMeta;
|
|
288
|
-
try {
|
|
289
|
-
const out = execFileSync("node", [
|
|
290
|
-
join(crewHome, "lib", "compute-publish-diff.js"),
|
|
291
|
-
"--repo-path", repoPath,
|
|
292
|
-
"--base", base,
|
|
293
|
-
"--out", diffPath,
|
|
294
|
-
], { encoding: "utf8" });
|
|
295
|
-
diffMeta = JSON.parse(out);
|
|
296
|
-
} catch (e) {
|
|
297
|
-
releaseLock();
|
|
298
|
-
fail("diff-failed", `compute-publish-diff.js failed: ${(e.stderr || e.message).slice(0, 300)} — intent note stands; next tick mirrors verification-requested`);
|
|
299
|
-
}
|
|
300
|
-
if (!diffMeta.ok) {
|
|
301
|
-
releaseLock();
|
|
302
|
-
fail("diff-not-ok", `compute-publish-diff.js returned ok:false — intent note stands; next tick mirrors verification-requested`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Success: lock remains HELD. The tick worker issues the edit directly
|
|
306
|
-
// in its own turn with the diff, then calls phase 2 (complete) to finish.
|
|
307
|
-
// Persist the phase-1 state for phase 2 (crash recovery reads the notes,
|
|
308
|
-
// but phase 2 needs the exact commit/attempt/baseline/ledger path).
|
|
309
|
-
const statePath = retryStatePath();
|
|
310
|
-
writeFileSync(statePath, JSON.stringify({
|
|
311
|
-
task_id: taskId, commit, attempt, slug, repoPath,
|
|
312
|
-
project_id: project.id, ledger_path: ledgerPath,
|
|
313
|
-
retry_baseline: retryBaseline,
|
|
314
|
-
diff_path: diffPath, diff_sha256: diffMeta.sha256,
|
|
315
|
-
prepared_at: nowISO(),
|
|
316
|
-
}));
|
|
317
|
-
|
|
318
|
-
process.stdout.write(JSON.stringify({
|
|
319
|
-
ok: true, phase: "prepare", outcome: "ready",
|
|
320
|
-
task_id: taskId, commit, attempt,
|
|
321
|
-
diff_path: diffPath, diff_sha256: diffMeta.sha256,
|
|
322
|
-
slug,
|
|
323
|
-
message: "lock held; issue the artifact_edit directly in this tick (the session-carrying tick worker owns issuance), then call phase 2 (complete)",
|
|
324
|
-
}) + "\n");
|
|
325
|
-
process.exit(0);
|
|
326
|
-
} catch (e) {
|
|
327
|
-
releaseLock();
|
|
328
|
-
throw e;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// --- Phase 2: complete -------------------------------------------------------
|
|
333
|
-
// Given the artifact_edit outcome, write the outcome notes, append the
|
|
334
|
-
// ledger entry, mirror verification, and RELEASE the lock (finally).
|
|
335
|
-
|
|
336
|
-
if (phase === "complete") {
|
|
337
|
-
const editOutcome = arg("--edit-outcome");
|
|
338
|
-
if (!["accepted", "refused"].includes(editOutcome)) {
|
|
339
|
-
console.error("--edit-outcome must be 'accepted' or 'refused'");
|
|
340
|
-
process.exit(2);
|
|
341
|
-
}
|
|
342
|
-
const refusalText = arg("--refusal-text", false, "");
|
|
343
|
-
|
|
344
|
-
const statePath = retryStatePath();
|
|
345
|
-
if (!existsSync(statePath)) {
|
|
346
|
-
fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
|
|
347
|
-
}
|
|
348
|
-
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
349
|
-
const { commit, attempt, slug, repoPath, ledger_path: ledgerPath, retry_baseline: retryBaseline } = state;
|
|
350
|
-
// (2026-09-20, one-party publish) The phase-1 pin carries repoPath; the
|
|
351
|
-
// merge lock is repo-scoped, so a missing value is a hard failure — the
|
|
352
|
-
// finally's release must never run against an undefined repo.
|
|
353
|
-
if (!repoPath) fail("no-repo-in-state", "phase-1 state has no repoPath — cannot release the merge lock safely; the lock lease expires on its own");
|
|
354
|
-
|
|
355
|
-
let lockHeld = true;
|
|
356
|
-
const releaseLock = () => {
|
|
357
|
-
if (lockHeld) {
|
|
358
|
-
runMergeLock("release", repoPath);
|
|
359
|
-
lockHeld = false;
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
try {
|
|
364
|
-
if (editOutcome === "refused") {
|
|
365
|
-
// Conclusive negative: rejected, not unknown. Terminal.
|
|
366
|
-
writePublishNote(db, taskId,
|
|
367
|
-
`publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
|
|
368
|
-
process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
|
|
369
|
-
} else {
|
|
370
|
-
// The tick worker's direct call ended with no explicit refusal —
|
|
371
|
-
// fire-and-forget, same shape as the first attempt.
|
|
372
|
-
writePublishNote(db, taskId,
|
|
373
|
-
`publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
|
|
374
|
-
|
|
375
|
-
// Mirror verification with a settle window.
|
|
376
|
-
const settleMs = Number(process.env.CREW_RETRY_SETTLE_MS) > 0
|
|
377
|
-
? Number(process.env.CREW_RETRY_SETTLE_MS)
|
|
378
|
-
: 20 * 60 * 1000;
|
|
379
|
-
const notBefore = new Date(Date.now() + settleMs).toISOString();
|
|
380
|
-
const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
|
|
381
|
-
writePublishNote(db, taskId,
|
|
382
|
-
`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.`);
|
|
383
|
-
|
|
384
|
-
// Append the -retry1 ledger entry with the pre-retry baseline (required).
|
|
385
|
-
const ledgerEntry = {
|
|
386
|
-
ts: nowISO(),
|
|
387
|
-
task_id: taskId,
|
|
388
|
-
workflow: "unknown-recovery",
|
|
389
|
-
slug,
|
|
390
|
-
commit,
|
|
391
|
-
attempt: retryAttempt,
|
|
392
|
-
agent_id: null,
|
|
393
|
-
applied_report: null,
|
|
394
|
-
outcome: "submitted",
|
|
395
|
-
issuer: "tick-worker",
|
|
396
|
-
manifest_before: retryBaseline,
|
|
397
|
-
detail: "retry trigger re-issued after provably-dropped classification",
|
|
398
|
-
};
|
|
399
|
-
try {
|
|
400
|
-
appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
|
|
401
|
-
} catch (e) {
|
|
402
|
-
// Best-effort: the note journal is the source of truth, not the ledger.
|
|
403
|
-
console.error(`ledger append failed (non-fatal): ${e.message}`);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
process.stdout.write(JSON.stringify({
|
|
407
|
-
ok: true, phase: "complete", outcome: "issued",
|
|
408
|
-
commit, attempt: retryAttempt, not_before: notBefore,
|
|
409
|
-
}) + "\n");
|
|
410
|
-
}
|
|
411
|
-
} finally {
|
|
412
|
-
releaseLock();
|
|
413
|
-
// Clean up the phase-1 state file (best-effort).
|
|
414
|
-
try { unlinkSync(statePath); } catch {}
|
|
415
|
-
}
|
|
416
|
-
process.exit(0);
|
|
417
|
-
}
|