muse-crew 0.13.3 → 0.14.1
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/publish-unknown-recovery.md +113 -0
- package/lib/AGENTS.md +7 -2
- package/lib/advance-publish-base.js +74 -28
- package/lib/classify-publish-absence.js +451 -0
- package/lib/crew-api.js +584 -21
- package/lib/publish-content.js +154 -0
- package/lib/retry-publish.js +370 -0
- package/lib/scaffold-crew.js +129 -0
- package/lib/verify-publish.js +101 -84
- package/package.json +2 -2
- package/seed/cron-body-template.md +57 -2
- package/workflows/bugfix.js +38 -23
- package/workflows/chore.js +37 -22
- package/workflows/crew-dispatch.js +43 -8
- package/workflows/crew-init.js +333 -78
- package/workflows/crew-uninstall.js +57 -10
- package/workflows/standard.js +35 -16
- package/workflows/upgrade.js +28 -14
package/lib/crew-api.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// get-project, kill-switch, get-config, update-config,
|
|
18
18
|
// set-provenance, get-provenance, acknowledge-poll,
|
|
19
19
|
// scan-verification-pending, resolve-publish-unknown,
|
|
20
|
+
// scan-publish-unknown, record-unknown-classification, record-retry-recheck,
|
|
20
21
|
// record-phase (composite), migrate (one-time import)
|
|
21
22
|
//
|
|
22
23
|
// Output is JSON on stdout. Errors are JSON on stderr.
|
|
@@ -1513,6 +1514,7 @@ commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
|
1513
1514
|
|
|
1514
1515
|
const toVerify = [];
|
|
1515
1516
|
const reconciled = [];
|
|
1517
|
+
const deferred = []; // not-before in the future (unknown-recovery retry) — claimed by a later tick
|
|
1516
1518
|
|
|
1517
1519
|
db.exec("BEGIN");
|
|
1518
1520
|
try {
|
|
@@ -1570,7 +1572,23 @@ commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
|
1570
1572
|
const procMatch = !commitMatch ? latest.match(/verification-procedural-error ([0-9a-f]{40})/) : null;
|
|
1571
1573
|
const commit = commitMatch ? commitMatch[1] : (procMatch ? procMatch[1] : null);
|
|
1572
1574
|
const buildMatch = req.message.match(/\(build ([0-9a-f-]{36})\)/);
|
|
1575
|
+
// Attempt binding (2026-09-18, blocker 4): the verifier must select the
|
|
1576
|
+
// submitted ledger entry for the exact (task_id, commit, attempt) —
|
|
1577
|
+
// not just (task_id, commit) — or a retry's trigger is indistinguishable
|
|
1578
|
+
// from the original's.
|
|
1579
|
+
const attemptMatch = req.message.match(/attempt=([^\s]+)/);
|
|
1580
|
+
const attempt = attemptMatch ? attemptMatch[1] : null;
|
|
1573
1581
|
if (!commit) continue; // malformed request: cannot verify mechanically, leave parked
|
|
1582
|
+
// not-before (2026-09-18, blocker 15): the unknown-recovery retry
|
|
1583
|
+
// protocol mirrors verification-requested with a not-before so the
|
|
1584
|
+
// re-triggered build gets 20 minutes to land before the verification
|
|
1585
|
+
// scan may claim it. Absent = verify immediately (backward compatible).
|
|
1586
|
+
const nbMatch = req.message.match(/not-before=([0-9T:.\-Z+]+)/);
|
|
1587
|
+
const notBeforeMs = nbMatch ? Date.parse(nbMatch[1]) : NaN;
|
|
1588
|
+
if (notBeforeMs > nowMs) {
|
|
1589
|
+
deferred.push({ task_id: row.task_id, commit, not_before: nbMatch[1] });
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1574
1592
|
db.prepare(
|
|
1575
1593
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1576
1594
|
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
@@ -1578,6 +1596,7 @@ commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
|
1578
1596
|
toVerify.push({
|
|
1579
1597
|
task_id: row.task_id,
|
|
1580
1598
|
commit: commit,
|
|
1599
|
+
attempt: attempt,
|
|
1581
1600
|
build_agent_id: buildMatch ? buildMatch[1] : null,
|
|
1582
1601
|
project_id: row.project_id,
|
|
1583
1602
|
repo_path: row.repo_path,
|
|
@@ -1588,7 +1607,7 @@ commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
|
1588
1607
|
}
|
|
1589
1608
|
db.exec("COMMIT");
|
|
1590
1609
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1591
|
-
return { to_verify: toVerify, reconciled };
|
|
1610
|
+
return { to_verify: toVerify, reconciled, deferred };
|
|
1592
1611
|
};
|
|
1593
1612
|
|
|
1594
1613
|
// Recovery contract for publish attempts parked with an UNKNOWN outcome
|
|
@@ -1605,20 +1624,31 @@ commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
|
1605
1624
|
// The parent's read-back (docs/publish-verification.md) remains the real
|
|
1606
1625
|
// verification and can still fail terminally. Never: blind retry, manual
|
|
1607
1626
|
// stamp, silent state change.
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1627
|
+
// ---------------------------------------------------------------------------
|
|
1628
|
+
// Shared publish-unknown attempt derivation (2026-09-18, blocker 15).
|
|
1629
|
+
//
|
|
1630
|
+
// The publish-unknown recovery state machine (scan-publish-unknown, the
|
|
1631
|
+
// manual resolve-publish-unknown, record-unknown-classification) all start
|
|
1632
|
+
// from the same durable anchors: the parked task, its project's deploy_slug,
|
|
1633
|
+
// and the latest publish ledger entry for the task, which must be outcome
|
|
1634
|
+
// "unknown" with a usable commit hash. This helper reads those anchors once
|
|
1635
|
+
// so the three commands cannot drift apart.
|
|
1636
|
+
//
|
|
1637
|
+
// Returns { ok: true, task, slug, repoPath, projectId, commit, attempt,
|
|
1638
|
+
// unknownEntry, unknownTs, ledgerPath, entries }
|
|
1639
|
+
// or { ok: false, reason } — the reason strings are the manual command's
|
|
1640
|
+
// exact messages (pinned by tests/resolve-publish-unknown.test.js).
|
|
1641
|
+
// ---------------------------------------------------------------------------
|
|
1642
|
+
function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false) {
|
|
1612
1643
|
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
1644
|
+
if (!task) return { ok: false, reason: "unknown task" };
|
|
1613
1645
|
if (task.state !== "parked") {
|
|
1614
|
-
return {
|
|
1646
|
+
return { ok: false, reason: `task is not parked (state=${task.state})` };
|
|
1615
1647
|
}
|
|
1616
|
-
const project = db.prepare("SELECT deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
1648
|
+
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
1617
1649
|
const slug = project && project.deploy_slug;
|
|
1618
|
-
if (!slug) return {
|
|
1619
|
-
|
|
1620
|
-
// The latest publish ledger entry for this task must be outcome "unknown".
|
|
1621
|
-
const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
|
|
1650
|
+
if (!slug) return { ok: false, reason: "project has no deploy_slug" };
|
|
1651
|
+
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
1622
1652
|
let entries = [];
|
|
1623
1653
|
try {
|
|
1624
1654
|
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
@@ -1626,19 +1656,119 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
|
|
|
1626
1656
|
.map((l) => JSON.parse(l))
|
|
1627
1657
|
.filter((e) => e.task_id === taskId);
|
|
1628
1658
|
} catch (e) {
|
|
1629
|
-
return {
|
|
1630
|
-
}
|
|
1631
|
-
if (entries.length === 0) return {
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1659
|
+
return { ok: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
1660
|
+
}
|
|
1661
|
+
if (entries.length === 0) return { ok: false, reason: "no publish ledger entries for this task" };
|
|
1662
|
+
// 2026-09-18, should-fix 6: for crash recovery (requireLatest=false),
|
|
1663
|
+
// derive by the most recent unknown attempt, not the ledger tail. Retry
|
|
1664
|
+
// or observability entries appended after the unknown must not break
|
|
1665
|
+
// recovery. For manual resolve (requireLatest=true), the unknown must be
|
|
1666
|
+
// the latest entry.
|
|
1667
|
+
let unknownEntry = null;
|
|
1668
|
+
if (requireLatest) {
|
|
1669
|
+
// Strict mode: latest entry must be unknown.
|
|
1670
|
+
const latest = entries[entries.length - 1];
|
|
1671
|
+
if (latest.outcome === "unknown-resolved") {
|
|
1672
|
+
return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
|
|
1673
|
+
}
|
|
1674
|
+
if (latest.outcome !== "unknown") {
|
|
1675
|
+
return { ok: false, reason: `latest ledger outcome is '${latest.outcome}', not unknown` };
|
|
1676
|
+
}
|
|
1677
|
+
unknownEntry = latest;
|
|
1678
|
+
} else {
|
|
1679
|
+
// Lenient mode: find the most recent unknown, skipping later non-unknown
|
|
1680
|
+
// entries (retry, observability). If an unknown-resolved follows the
|
|
1681
|
+
// unknown, the attempt is resolved.
|
|
1682
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1683
|
+
const e = entries[i];
|
|
1684
|
+
if (e.outcome === "unknown-resolved") {
|
|
1685
|
+
return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
|
|
1686
|
+
}
|
|
1687
|
+
if (e.outcome === "unknown") {
|
|
1688
|
+
unknownEntry = e;
|
|
1689
|
+
break;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (!unknownEntry) {
|
|
1693
|
+
return { ok: false, reason: "no unresolved unknown ledger entry for this task" };
|
|
1694
|
+
}
|
|
1635
1695
|
}
|
|
1636
|
-
if (
|
|
1637
|
-
return {
|
|
1696
|
+
if (!unknownEntry.commit || !/^[0-9a-f]{40}$/.test(unknownEntry.commit)) {
|
|
1697
|
+
return { ok: false, reason: "unknown ledger entry carries no usable commit hash" };
|
|
1638
1698
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1699
|
+
return {
|
|
1700
|
+
ok: true, task, slug, repoPath: (project && project.repo_path) || null, projectId: project.id,
|
|
1701
|
+
commit: unknownEntry.commit, attempt: unknownEntry.attempt || null,
|
|
1702
|
+
unknownEntry, unknownTs: unknownEntry.ts || null, ledgerPath, entries,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// The unknown-recovery state machine reads two note shapes: the machine's
|
|
1707
|
+
// own "publish: <transition>" notes, and the workflow's initial
|
|
1708
|
+
// "Publish outcome unknown …" park note — which carries no "publish:"
|
|
1709
|
+
// marker (composeUnattributedParkReason), so the prefilter must match it
|
|
1710
|
+
// explicitly. LIKE is ASCII case-insensitive in SQLite.
|
|
1711
|
+
const PUBLISH_NOTE_PREFILTER =
|
|
1712
|
+
`(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
|
|
1713
|
+
|
|
1714
|
+
// Latest publish: note for a task (the unknown-recovery state machine's
|
|
1715
|
+
// state), or null when the task has no publish notes yet.
|
|
1716
|
+
function latestPublishNote(db, taskId) {
|
|
1717
|
+
return db.prepare(
|
|
1718
|
+
`SELECT message, timestamp FROM events
|
|
1719
|
+
WHERE task_id = ? AND type = 'note' AND ${PUBLISH_NOTE_PREFILTER}
|
|
1720
|
+
ORDER BY timestamp DESC LIMIT 1`
|
|
1721
|
+
).get(taskId) || null;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// The trigger anchor for an unknown attempt: the "submitted" ledger entry
|
|
1725
|
+
// for the same attempt, recorded when the trigger went out. Returns the
|
|
1726
|
+
// entry or null. A null means the trigger instant is unknowable (or the
|
|
1727
|
+
// trigger was never issued) — classification cannot run; the task stays
|
|
1728
|
+
// parked for human attention. Fail closed, never guess the anchor.
|
|
1729
|
+
function findTriggerEntry(entries, commit, attempt) {
|
|
1730
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1731
|
+
const e = entries[i];
|
|
1732
|
+
if (e.outcome === "submitted" && e.commit === commit &&
|
|
1733
|
+
(e.attempt || null) === (attempt || null)) {
|
|
1734
|
+
return e;
|
|
1735
|
+
}
|
|
1641
1736
|
}
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// The unknown ledger entry's detail says whether the trigger went out.
|
|
1741
|
+
// Unknown parks from preflight/toolcheck inconclusives never issued the
|
|
1742
|
+
// trigger ("the trigger was NOT issued") — there is no dropped edit to
|
|
1743
|
+
// recover, so the unknown-recovery machine must not classify or retry them.
|
|
1744
|
+
function triggerWasIssued(unknownEntry) {
|
|
1745
|
+
const detail = String((unknownEntry && unknownEntry.detail) || "");
|
|
1746
|
+
return !/trigger was NOT issued|no trigger issued/i.test(detail);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function writePublishNote(db, taskId, message, ts) {
|
|
1750
|
+
db.prepare(
|
|
1751
|
+
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1752
|
+
VALUES (?, 'note', ?, 'crew', ?, ?)`
|
|
1753
|
+
).run(uuid(), taskId, message, ts || now());
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
commands["resolve-publish-unknown"] = (db, args, ctx) => {
|
|
1757
|
+
const taskId = args.task_id;
|
|
1758
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
1759
|
+
requireTask(db, taskId);
|
|
1760
|
+
// Shared attempt anchors (2026-09-18, blocker 15): the task must be
|
|
1761
|
+
// parked, the project must carry a deploy_slug, and the latest publish
|
|
1762
|
+
// ledger entry for the task must be outcome "unknown" with a usable
|
|
1763
|
+
// commit. derivePublishUnknownAttempt keeps the exact reason strings the
|
|
1764
|
+
// recovery contract pins (tests/resolve-publish-unknown.test.js).
|
|
1765
|
+
// requireLatest=true: resolve is a manual action on the current state —
|
|
1766
|
+
// a later non-unknown entry means the state has moved on.
|
|
1767
|
+
const derived = derivePublishUnknownAttempt(db, ctx.crewHome, taskId, true);
|
|
1768
|
+
if (!derived.ok) return { resolved: false, reason: derived.reason };
|
|
1769
|
+
const slug = derived.slug;
|
|
1770
|
+
const ledgerPath = derived.ledgerPath;
|
|
1771
|
+
const latest = derived.unknownEntry;
|
|
1642
1772
|
|
|
1643
1773
|
// The publish window: Integrate completion -> the unknown-outcome park.
|
|
1644
1774
|
// Nothing else touches the artifact during Publish, so an audit dir
|
|
@@ -1733,6 +1863,431 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
|
|
|
1733
1863
|
return { resolved: true, task_id: taskId, commit: latest.commit, evidence_dir: evidenceDir, audit_report_ok: auditOk };
|
|
1734
1864
|
};
|
|
1735
1865
|
|
|
1866
|
+
// Cron unknown-recovery scan (2026-09-18, blocker 15). Runs in cron Step 4.5
|
|
1867
|
+
// BEFORE scan-verification-pending, so a task the classifier verifies (or
|
|
1868
|
+
// the retry protocol re-triggers) is claimed by the verification scan on
|
|
1869
|
+
// the same tick.
|
|
1870
|
+
//
|
|
1871
|
+
// The note is the state machine; this command is the only writer of its
|
|
1872
|
+
// transitions. The tick ferries classifier decisions and retry steps — code
|
|
1873
|
+
// decides every state change. One atomic transaction per scan: claims,
|
|
1874
|
+
// mirrors, and terminal notes all land together or not at all.
|
|
1875
|
+
//
|
|
1876
|
+
// latest publish: note this scan
|
|
1877
|
+
// "Publish outcome unknown…" now < park+30m → waiting (no claim, log only)
|
|
1878
|
+
// else → atomic 1h claim → due, with classifier inputs
|
|
1879
|
+
// "publish: unknown-recovery-claimed …" unexpired → skip (a tick owns it)
|
|
1880
|
+
// expired → re-claim → due (the tick died mid-classification)
|
|
1881
|
+
// "publish: dropped …" budget spent (retry-issued in history) → terminal
|
|
1882
|
+
// publish: ambiguous … retry-budget-exhausted
|
|
1883
|
+
// else → retry_due (the tick runs the retry protocol)
|
|
1884
|
+
// "publish: retry-intended …" w/o → mirror publish: verification-requested
|
|
1885
|
+
// "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
|
|
1886
|
+
// "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
|
|
1887
|
+
// verification-requested
|
|
1888
|
+
// "publish: ambiguous …" / "publish: → terminal; ignored
|
|
1889
|
+
// retry-superseded" / "publish:
|
|
1890
|
+
// retry-refused" / "publish: verified" /
|
|
1891
|
+
// "publish: superseded"
|
|
1892
|
+
// "publish: verification-requested" → already routed; ignored (the verification scan owns it)
|
|
1893
|
+
//
|
|
1894
|
+
// Unknown parks whose trigger was never issued (preflight/toolcheck
|
|
1895
|
+
// inconclusives) or whose submitted ledger entry is missing have no
|
|
1896
|
+
// knowable trigger anchor: the scan leaves them parked for human attention.
|
|
1897
|
+
// Fail closed, never guess.
|
|
1898
|
+
commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
1899
|
+
const crewHome = ctx.crewHome;
|
|
1900
|
+
const nowMs = Date.now();
|
|
1901
|
+
const quiesceMs = 30 * 60 * 1000; // design §1.4e: never trust a read before the park is 30m old
|
|
1902
|
+
const claimLeaseMs = 60 * 60 * 1000; // one hour — survives a dead tick, does not block the next one
|
|
1903
|
+
const retrySettleMs = 20 * 60 * 1000; // a re-triggered build gets 20m before verification may claim it
|
|
1904
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1905
|
+
|
|
1906
|
+
const waiting = [];
|
|
1907
|
+
const due = [];
|
|
1908
|
+
const retryDue = [];
|
|
1909
|
+
const mirrored = [];
|
|
1910
|
+
const skipped = [];
|
|
1911
|
+
|
|
1912
|
+
const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
|
|
1913
|
+
|
|
1914
|
+
// Shared tail of the unknown branches: derive anchors, check the trigger
|
|
1915
|
+
// went out, find the trigger anchor, write the atomic claim, emit the
|
|
1916
|
+
// classifier inputs. parkNote is the /publish outcome unknown/i note.
|
|
1917
|
+
const claimForClassification = (taskId, notes, parkNote) => {
|
|
1918
|
+
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
1919
|
+
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); return; }
|
|
1920
|
+
if (!triggerWasIssued(derived.unknownEntry)) {
|
|
1921
|
+
skip(taskId, "trigger-never-issued",
|
|
1922
|
+
"the unknown ledger entry records that the trigger was never issued — no dropped edit to recover; stays parked for human attention");
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const trigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
|
|
1926
|
+
if (!trigger || !trigger.ts) {
|
|
1927
|
+
skip(taskId, "no-trigger-anchor",
|
|
1928
|
+
"no submitted ledger entry for this attempt — the trigger instant is unknowable; stays parked for human attention");
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
// Atomic claim: the note IS the claim, written inside this transaction
|
|
1932
|
+
// only while the latest publish: note is still the expected one (the
|
|
1933
|
+
// caller re-checks freshness before invoking this).
|
|
1934
|
+
const expiry = new Date(nowMs + claimLeaseMs).toISOString();
|
|
1935
|
+
writePublishNote(db, taskId,
|
|
1936
|
+
`publish: unknown-recovery-claimed ${expiry} — ${taskId} parked unknown at ${parkNote.timestamp}; claimed for classification (1-hour lease).`,
|
|
1937
|
+
new Date(nowMs).toISOString());
|
|
1938
|
+
// Classifier base: the last verified publish, or the empty tree when
|
|
1939
|
+
// nothing is stamped yet (a first publish) — same convention as the
|
|
1940
|
+
// Step 4.5 read-back sensor.
|
|
1941
|
+
// 2026-09-18, should-fix 4: provenance query failures fail CLOSED —
|
|
1942
|
+
// the empty tree is valid only when there is truly no provenance value.
|
|
1943
|
+
// A query error (locked DB, I/O failure) is not "no provenance"; skip
|
|
1944
|
+
// the task so the next scan retries.
|
|
1945
|
+
let base;
|
|
1946
|
+
try {
|
|
1947
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(derived.projectId);
|
|
1948
|
+
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
|
|
1949
|
+
base = prov.provenance_source_commit;
|
|
1950
|
+
} else {
|
|
1951
|
+
base = EMPTY_TREE; // truly no provenance value: first publish
|
|
1952
|
+
}
|
|
1953
|
+
} catch (e) {
|
|
1954
|
+
skip(taskId, "provenance-query-failed",
|
|
1955
|
+
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
due.push({
|
|
1959
|
+
task_id: taskId, commit: derived.commit, base,
|
|
1960
|
+
trigger_ts: trigger.ts, park_ts: parkNote.timestamp,
|
|
1961
|
+
slug: derived.slug, repo_path: derived.repoPath,
|
|
1962
|
+
project_id: derived.projectId, claim_expiry: expiry,
|
|
1963
|
+
ledger_path: derived.ledgerPath,
|
|
1964
|
+
// Pre-trigger manifest baseline (design §1.9): the workflow snapshots
|
|
1965
|
+
// the manifest before the trigger into the submitted ledger entry; the
|
|
1966
|
+
// classifier's verified path compares the current manifest against it.
|
|
1967
|
+
// Null when the workflow predates the capture (fail closed downstream).
|
|
1968
|
+
manifest_before: (trigger.manifest_before && typeof trigger.manifest_before === "object")
|
|
1969
|
+
? trigger.manifest_before : null,
|
|
1970
|
+
});
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
|
|
1974
|
+
|
|
1975
|
+
db.exec("BEGIN");
|
|
1976
|
+
try {
|
|
1977
|
+
for (const row of parked) {
|
|
1978
|
+
const taskId = row.task_id;
|
|
1979
|
+
const notes = db.prepare(
|
|
1980
|
+
`SELECT message, timestamp FROM events
|
|
1981
|
+
WHERE task_id = ? AND type = 'note' AND ${PUBLISH_NOTE_PREFILTER}
|
|
1982
|
+
ORDER BY timestamp DESC LIMIT 40`
|
|
1983
|
+
).all(taskId);
|
|
1984
|
+
if (notes.length === 0) continue;
|
|
1985
|
+
const latest = notes[0];
|
|
1986
|
+
const msg = latest.message;
|
|
1987
|
+
const has = (s) => msg.includes(s);
|
|
1988
|
+
const historyHas = (s) => notes.some((n) => n.message.includes(s));
|
|
1989
|
+
|
|
1990
|
+
// Terminal states — the machine never revisits these.
|
|
1991
|
+
if (has("publish: ambiguous") || has("publish: retry-superseded") ||
|
|
1992
|
+
has("publish: retry-refused") || has("publish: verified") ||
|
|
1993
|
+
has("publish: superseded")) {
|
|
1994
|
+
skip(taskId, "terminal");
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
// Already routed into the verification pipeline — the verification
|
|
1998
|
+
// scan owns it from here.
|
|
1999
|
+
if (has("publish: verification-requested")) {
|
|
2000
|
+
skip(taskId, "routed-to-verification");
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
// A retry went out but the mirrored verification request is missing
|
|
2004
|
+
// (the tick died between the two writes). Mirror it now — the retry is
|
|
2005
|
+
// at least one tick old, so no not-before is needed.
|
|
2006
|
+
if (has("publish: retry-issued")) {
|
|
2007
|
+
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2008
|
+
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2009
|
+
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2010
|
+
writePublishNote(db, taskId,
|
|
2011
|
+
`publish: verification-requested ${derived.commit} attempt=${derived.attempt} — mirrored by scan-publish-unknown: the retry was issued without a mirrored request (the tick died between the writes). Waiting on the manual read-back in docs/publish-verification.md.`);
|
|
2012
|
+
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: null, reason: "retry-issued-without-mirror" });
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
// A retry-intended note with no retry-issued: the tick died mid-retry
|
|
2016
|
+
// and the trigger may or may not have gone out. NEVER re-trigger here —
|
|
2017
|
+
// route to the verification pipeline and let content verification be
|
|
2018
|
+
// the arbiter (design §1.5 R3).
|
|
2019
|
+
if (has("publish: retry-intended")) {
|
|
2020
|
+
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2021
|
+
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2022
|
+
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2023
|
+
const m = msg.match(/publish: retry-intended\s+[0-9a-f]{40}\s+(\S+)/);
|
|
2024
|
+
const intendedMs = m ? Date.parse(m[1]) : NaN;
|
|
2025
|
+
const notBefore = new Date(Math.max(nowMs, (intendedMs > 0 ? intendedMs : nowMs) + retrySettleMs)).toISOString();
|
|
2026
|
+
writePublishNote(db, taskId,
|
|
2027
|
+
`publish: verification-requested ${derived.commit} attempt=${derived.attempt} not-before=${notBefore} — mirrored by scan-publish-unknown: a retry-intended note exists without retry-issued (the tick died mid-retry), so the trigger may or may not have gone out; never re-triggering here, content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
|
|
2028
|
+
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: notBefore, reason: "retry-intended-without-retry-issued" });
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
// Classified as dropped and awaiting the retry protocol. Exactly one
|
|
2032
|
+
// retry, enforced from note events (design §1.5): a retry-issued
|
|
2033
|
+
// anywhere in this task's history spends the budget — including a
|
|
2034
|
+
// retry for an earlier unknown attempt on a reworked task. When the
|
|
2035
|
+
// budget is spent the drop is terminal: ambiguous, human attention.
|
|
2036
|
+
if (has("publish: dropped")) {
|
|
2037
|
+
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2038
|
+
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2039
|
+
if (historyHas("publish: retry-issued")) {
|
|
2040
|
+
writePublishNote(db, taskId,
|
|
2041
|
+
`publish: ambiguous ${derived.commit} — retry budget exhausted: a publish retry was already issued for this task in an earlier attempt; not retrying blind. Parked for human attention.`);
|
|
2042
|
+
try {
|
|
2043
|
+
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2044
|
+
ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2045
|
+
commit: derived.commit, attempt: derived.attempt, agent_id: null,
|
|
2046
|
+
applied_report: null, outcome: "unknown-classified",
|
|
2047
|
+
detail: "retry budget exhausted — ambiguous, terminal",
|
|
2048
|
+
}) + "\n");
|
|
2049
|
+
} catch (e) { /* best-effort observability */ }
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
// Retry budget free: queue the retry protocol. The tick needs the
|
|
2053
|
+
// classifier base to regenerate the publish diff byte-identically
|
|
2054
|
+
// (compute-publish-diff.js --base), so ferry it from provenance.
|
|
2055
|
+
// The in-lock re-check also needs the trigger anchor and the
|
|
2056
|
+
// pre-trigger manifest baseline (design §1.9).
|
|
2057
|
+
const retryProv = db.prepare(
|
|
2058
|
+
`SELECT provenance_source_commit FROM projects WHERE id = ?`
|
|
2059
|
+
).get(derived.projectId);
|
|
2060
|
+
const retryBase = (retryProv && retryProv.provenance_source_commit) || EMPTY_TREE;
|
|
2061
|
+
const retryTrigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
|
|
2062
|
+
retryDue.push({
|
|
2063
|
+
task_id: taskId, commit: derived.commit, attempt: derived.attempt,
|
|
2064
|
+
slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
|
|
2065
|
+
ledger_path: derived.ledgerPath, base: retryBase,
|
|
2066
|
+
trigger_ts: (retryTrigger && retryTrigger.ts) || null,
|
|
2067
|
+
manifest_before: (retryTrigger && retryTrigger.manifest_before &&
|
|
2068
|
+
typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
|
|
2069
|
+
});
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
// A previous tick claimed this task for classification.
|
|
2073
|
+
// A previous tick claimed this task for classification.
|
|
2074
|
+
if (has("publish: unknown-recovery-claimed")) {
|
|
2075
|
+
const m = msg.match(/publish: unknown-recovery-claimed\s+(\S+)/);
|
|
2076
|
+
const expiryMs = m ? Date.parse(m[1]) : NaN;
|
|
2077
|
+
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
2078
|
+
// Expired claim: the tick died mid-classification. Re-derive from
|
|
2079
|
+
// the original unknown note and re-claim.
|
|
2080
|
+
const parkNote = notes.find((n) => /publish outcome unknown/i.test(n.message));
|
|
2081
|
+
if (!parkNote) { skip(taskId, "no-unknown-note", "expired claim with no unknown park note in history"); continue; }
|
|
2082
|
+
const fresh = latestPublishNote(db, taskId);
|
|
2083
|
+
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2084
|
+
claimForClassification(taskId, notes, parkNote);
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
// The initial unknown park note. Quiescence first: the classifier must
|
|
2088
|
+
// never read a manifest younger than 30 minutes past the park.
|
|
2089
|
+
if (/publish outcome unknown/i.test(msg)) {
|
|
2090
|
+
const parkMs = Date.parse(latest.timestamp);
|
|
2091
|
+
if (!(parkMs > 0) || nowMs < parkMs + quiesceMs) {
|
|
2092
|
+
waiting.push({ task_id: taskId, parked_at: latest.timestamp });
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
// Re-check freshness inside the transaction: the latest publish:
|
|
2096
|
+
// note must still be this unknown note.
|
|
2097
|
+
const fresh = latestPublishNote(db, taskId);
|
|
2098
|
+
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2099
|
+
claimForClassification(taskId, notes, latest);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
skip(taskId, "unrecognized-publish-note");
|
|
2103
|
+
}
|
|
2104
|
+
db.exec("COMMIT");
|
|
2105
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2106
|
+
|
|
2107
|
+
return { waiting, due, retry_due: retryDue, mirrored, skipped };
|
|
2108
|
+
};
|
|
2109
|
+
|
|
2110
|
+
// Record the unknown-recovery classifier's decision (2026-09-18, blocker
|
|
2111
|
+
// 15). The tick ferries: it runs lib/classify-publish-absence.js for each
|
|
2112
|
+
// scan-publish-unknown "due" entry and passes the classifier's JSON here.
|
|
2113
|
+
// Code decides: every note the decision implies is written here, atomically,
|
|
2114
|
+
// never free-formed by the tick.
|
|
2115
|
+
//
|
|
2116
|
+
// Args: { task_id, claim_expiry, decision } where decision is the
|
|
2117
|
+
// classifier's { ok, decision, reasons, details } JSON.
|
|
2118
|
+
//
|
|
2119
|
+
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
2120
|
+
// latest publish: note is still this tick's claim. A stale tick (its claim
|
|
2121
|
+
// expired and a newer tick re-claimed) gets { recorded: false } and writes
|
|
2122
|
+
// nothing.
|
|
2123
|
+
//
|
|
2124
|
+
// Routing (design §1.4/§1.5):
|
|
2125
|
+
// verified → publish: verification-requested (no not-before: the
|
|
2126
|
+
// content is already the new state) + unknown-resolved
|
|
2127
|
+
// ledger entry; the verification scan claims it.
|
|
2128
|
+
// provably-dropped → publish: dropped (the next scan queues the retry
|
|
2129
|
+
// protocol). No ledger resolution — the attempt is
|
|
2130
|
+
// still logically unknown.
|
|
2131
|
+
// applied-not-built / ambiguous
|
|
2132
|
+
// → publish: ambiguous (terminal) + unknown-classified
|
|
2133
|
+
// ledger entry. Parked for human attention.
|
|
2134
|
+
// deferred → no note: the claim expires and the next scan
|
|
2135
|
+
// re-claims. A deferred classification is not a verdict.
|
|
2136
|
+
// superseded → publish: superseded (terminal) + unknown-classified
|
|
2137
|
+
// ledger entry. HEAD moved; recovery aborted.
|
|
2138
|
+
commands["record-unknown-classification"] = (db, args, ctx) => {
|
|
2139
|
+
const taskId = args.task_id;
|
|
2140
|
+
const claimExpiry = args.claim_expiry;
|
|
2141
|
+
const decision = args.decision;
|
|
2142
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2143
|
+
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
2144
|
+
if (!decision || typeof decision !== "object") throw usageError("decision is required (the classifier's JSON).");
|
|
2145
|
+
const verdict = decision.decision;
|
|
2146
|
+
const valid = ["verified", "provably-dropped", "applied-not-built", "ambiguous", "deferred", "superseded"];
|
|
2147
|
+
if (!valid.includes(verdict)) throw usageError(`unknown classifier decision: ${verdict}.`);
|
|
2148
|
+
|
|
2149
|
+
db.exec("BEGIN");
|
|
2150
|
+
try {
|
|
2151
|
+
// CAS: the latest publish: note must still be this tick's claim.
|
|
2152
|
+
const latest = latestPublishNote(db, taskId);
|
|
2153
|
+
if (!latest || !latest.message.includes("publish: unknown-recovery-claimed") ||
|
|
2154
|
+
!latest.message.includes(claimExpiry)) {
|
|
2155
|
+
db.exec("ROLLBACK");
|
|
2156
|
+
return { recorded: false, reason: "claim no longer current — another tick owns this task" };
|
|
2157
|
+
}
|
|
2158
|
+
const derived = derivePublishUnknownAttempt(db, ctx.crewHome, taskId);
|
|
2159
|
+
if (!derived.ok) {
|
|
2160
|
+
db.exec("ROLLBACK");
|
|
2161
|
+
return { recorded: false, reason: derived.reason };
|
|
2162
|
+
}
|
|
2163
|
+
const reasons = Array.isArray(decision.reasons) ? decision.reasons.join("; ") : "";
|
|
2164
|
+
const ts = now();
|
|
2165
|
+
|
|
2166
|
+
const appendLedger = (outcome, detail) => {
|
|
2167
|
+
try {
|
|
2168
|
+
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2169
|
+
ts, task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2170
|
+
commit: derived.commit, attempt: derived.attempt, agent_id: null,
|
|
2171
|
+
applied_report: null, outcome, detail,
|
|
2172
|
+
}) + "\n");
|
|
2173
|
+
} catch (e) { /* best-effort observability */ }
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
if (verdict === "deferred") {
|
|
2177
|
+
// No note: the claim expires, the next scan re-claims and retries the
|
|
2178
|
+
// classification. A deferred classification is explicitly not a verdict.
|
|
2179
|
+
db.exec("COMMIT");
|
|
2180
|
+
return { recorded: true, decision: verdict, task_id: taskId, note: "deferred — claim left to expire" };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
let note;
|
|
2184
|
+
if (verdict === "verified") {
|
|
2185
|
+
note = `publish: verification-requested ${derived.commit} attempt=${derived.attempt} — unknown-recovery classifier verified the content already shows the new state (${reasons}); routed without re-trigger. Waiting on the manual read-back in docs/publish-verification.md.`;
|
|
2186
|
+
writePublishNote(db, taskId, note, ts);
|
|
2187
|
+
appendLedger("unknown-resolved",
|
|
2188
|
+
`classifier verified: content shows the new state; routed to parent verification without re-trigger (${reasons})`);
|
|
2189
|
+
} else if (verdict === "provably-dropped") {
|
|
2190
|
+
note = `publish: dropped ${derived.commit} — unknown-recovery classifier: the trigger went out and the platform provably dropped the edit (${reasons}); queued for the retry protocol.`;
|
|
2191
|
+
writePublishNote(db, taskId, note, ts);
|
|
2192
|
+
// No ledger resolution: the attempt is still logically unknown until
|
|
2193
|
+
// the retry protocol journals its outcome.
|
|
2194
|
+
} else if (verdict === "superseded") {
|
|
2195
|
+
note = `publish: superseded ${derived.commit} — unknown-recovery classifier: HEAD moved past the attempt commit (${reasons}); recovery aborted. Parked for human attention.`;
|
|
2196
|
+
writePublishNote(db, taskId, note, ts);
|
|
2197
|
+
appendLedger("unknown-classified", `classifier superseded — terminal (${reasons})`);
|
|
2198
|
+
} else {
|
|
2199
|
+
// applied-not-built, ambiguous: the classifier could not prove the
|
|
2200
|
+
// drop. Terminal — parked for human attention, never retried blind.
|
|
2201
|
+
note = `publish: ambiguous ${derived.commit} — unknown-recovery classifier could not prove the edit was dropped (${verdict}: ${reasons}); not retrying blind. Parked for human attention.`;
|
|
2202
|
+
writePublishNote(db, taskId, note, ts);
|
|
2203
|
+
appendLedger("unknown-classified", `classifier ${verdict} — terminal, no retry (${reasons})`);
|
|
2204
|
+
}
|
|
2205
|
+
db.exec("COMMIT");
|
|
2206
|
+
return { recorded: true, decision: verdict, task_id: taskId, note };
|
|
2207
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2208
|
+
};
|
|
2209
|
+
|
|
2210
|
+
// Retry-protocol re-check routing (2026-09-18, blocker 15). The tick runs
|
|
2211
|
+
// the retry protocol from inside the merge lock; its pre-trigger content
|
|
2212
|
+
// re-read (lib/classify-publish-absence.js) can come back something other
|
|
2213
|
+
// than provably-dropped. This command routes that decision — code-owned, so
|
|
2214
|
+
// the tick never free-forms the state machine.
|
|
2215
|
+
//
|
|
2216
|
+
// Args: { task_id, decision } where decision is the classifier's JSON
|
|
2217
|
+
// ({ ok:true, decision, reasons, details }).
|
|
2218
|
+
// CAS: the latest publish: note must still be "publish: dropped" — the merge
|
|
2219
|
+
// lock is the mutual exclusion; the CAS is the backstop against a tick that
|
|
2220
|
+
// died after another writer moved the state.
|
|
2221
|
+
//
|
|
2222
|
+
// verified → publish: verification-requested (content is
|
|
2223
|
+
// present now; no not-before) + unknown-resolved
|
|
2224
|
+
// ledger entry.
|
|
2225
|
+
// provably-dropped → no-op: the tick proceeds with the retry.
|
|
2226
|
+
// applied-not-built / ambiguous → publish: ambiguous (terminal).
|
|
2227
|
+
// superseded → publish: superseded (terminal).
|
|
2228
|
+
// deferred → no-op: the drop note stays latest; the next scan
|
|
2229
|
+
// re-queues the protocol and re-reads.
|
|
2230
|
+
commands["record-retry-recheck"] = (db, args, ctx) => {
|
|
2231
|
+
const crewHome = ctx?.crewHome;
|
|
2232
|
+
if (!crewHome) throw usageError("crewHome is required (internal context).");
|
|
2233
|
+
const taskId = String(args?.task_id ?? "").trim();
|
|
2234
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2235
|
+
const decision = args?.decision;
|
|
2236
|
+
if (!decision || typeof decision !== "object" || decision.ok !== true) {
|
|
2237
|
+
throw usageError("decision is required — pass the classifier's JSON (ok:true).");
|
|
2238
|
+
}
|
|
2239
|
+
const valid = ["verified", "provably-dropped", "applied-not-built", "ambiguous", "deferred", "superseded"];
|
|
2240
|
+
if (!valid.includes(decision.decision)) {
|
|
2241
|
+
throw usageError(`unknown classifier decision: ${decision.decision}.`);
|
|
2242
|
+
}
|
|
2243
|
+
requireTask(db, taskId);
|
|
2244
|
+
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2245
|
+
if (!derived.ok) throw usageError(`cannot derive the unknown attempt: ${derived.reason}.`);
|
|
2246
|
+
const latest = latestPublishNote(db, taskId);
|
|
2247
|
+
if (!latest || !latest.message.includes("publish: dropped")) {
|
|
2248
|
+
return {
|
|
2249
|
+
recorded: false,
|
|
2250
|
+
reason: "latest publish: note is no longer publish: dropped — another writer moved the state; recorded nothing.",
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
const d = decision.decision;
|
|
2254
|
+
const reasons = Array.isArray(decision.reasons) ? decision.reasons.join("; ") : "";
|
|
2255
|
+
const commit = derived.commit;
|
|
2256
|
+
if (d === "provably-dropped" || d === "deferred") {
|
|
2257
|
+
// No state change: the tick proceeds (dropped) or the next scan
|
|
2258
|
+
// re-queues the protocol (deferred).
|
|
2259
|
+
return { recorded: false, reason: `re-check decision ${d}: no state change; the drop note stands.` };
|
|
2260
|
+
}
|
|
2261
|
+
if (d === "verified") {
|
|
2262
|
+
writePublishNote(db, taskId,
|
|
2263
|
+
`publish: verification-requested ${commit} attempt=${derived.attempt} — unknown-recovery retry re-check: the content is present now, so no retry was needed. Waiting on the manual read-back in docs/publish-verification.md.`);
|
|
2264
|
+
try {
|
|
2265
|
+
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2266
|
+
ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2267
|
+
commit, attempt: derived.attempt, agent_id: null, applied_report: null,
|
|
2268
|
+
outcome: "unknown-resolved",
|
|
2269
|
+
detail: `retry re-check verified (was: ${reasons})`,
|
|
2270
|
+
}) + "\n");
|
|
2271
|
+
} catch (e) { /* best-effort observability */ }
|
|
2272
|
+
return { recorded: true, decision: d, routed: "verification-requested" };
|
|
2273
|
+
}
|
|
2274
|
+
// applied-not-built / ambiguous → terminal; superseded → terminal.
|
|
2275
|
+
const terminal = d === "superseded" ? "superseded" : "ambiguous";
|
|
2276
|
+
const noteBody = terminal === "superseded"
|
|
2277
|
+
? `publish: superseded ${commit} — unknown-recovery retry re-check: the retry target moved (${reasons}); not retrying a stale commit. Parked for human attention.`
|
|
2278
|
+
: `publish: ambiguous ${commit} — unknown-recovery retry re-check: ${reasons}; not retrying blind. Parked for human attention.`;
|
|
2279
|
+
writePublishNote(db, taskId, noteBody);
|
|
2280
|
+
try {
|
|
2281
|
+
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2282
|
+
ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2283
|
+
commit, attempt: derived.attempt, agent_id: null, applied_report: null,
|
|
2284
|
+
outcome: "unknown-classified",
|
|
2285
|
+
detail: `retry re-check ${d}: terminal (${reasons})`,
|
|
2286
|
+
}) + "\n");
|
|
2287
|
+
} catch (e) { /* best-effort observability */ }
|
|
2288
|
+
return { recorded: true, decision: d, routed: terminal };
|
|
2289
|
+
};
|
|
2290
|
+
|
|
1736
2291
|
// Composite: record a phase's session verdict AND its event in one
|
|
1737
2292
|
// transaction. This is the operation that used to crash the workflow when the
|
|
1738
2293
|
// two writes went to different contracts: the session stored, the event
|
|
@@ -2070,6 +2625,14 @@ commands["update-config"] = (db, args) => {
|
|
|
2070
2625
|
// projects carries N independent publish bases, and stamping one project
|
|
2071
2626
|
// never touches another's row.
|
|
2072
2627
|
function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
|
|
2628
|
+
// task_id invariant (2026-09-19 REVIEW): task_id is set by every REAL
|
|
2629
|
+
// publish (lib/verify-publish.js and the workflow provenance-refresh paths)
|
|
2630
|
+
// and is null for initial stamps (tryStampInitialProvenance) and for
|
|
2631
|
+
// publish-base advances (lib/advance-publish-base.js). So
|
|
2632
|
+
// provenance.task_id != null ⟺ a real publish has stamped this project.
|
|
2633
|
+
// lib/advance-publish-base.js relies on this to deterministically refuse to
|
|
2634
|
+
// move a publish's base. Keep it true: any new real-publish path must pass
|
|
2635
|
+
// its task_id; anything that is not a publish must leave it null.
|
|
2073
2636
|
const pid = String(project_id ?? "").trim();
|
|
2074
2637
|
if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
|
|
2075
2638
|
requireProject(db, pid);
|