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/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
- commands["resolve-publish-unknown"] = (db, args, ctx) => {
1609
- const taskId = args.task_id;
1610
- if (!taskId) throw usageError("task_id is required.");
1611
- requireTask(db, taskId);
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 { resolved: false, reason: `task is not parked (state=${task.state})` };
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 { resolved: false, reason: "project has no deploy_slug" };
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 { resolved: false, reason: `cannot read publish ledger: ${e.message}` };
1630
- }
1631
- if (entries.length === 0) return { resolved: false, reason: "no publish ledger entries for this task" };
1632
- const latest = entries[entries.length - 1];
1633
- if (latest.outcome === "unknown-resolved") {
1634
- return { resolved: false, reason: "already resolved (unknown-resolved ledger entry present)" };
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 (latest.outcome !== "unknown") {
1637
- return { resolved: false, reason: `latest ledger outcome is '${latest.outcome}', not unknown` };
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
- if (!latest.commit || !/^[0-9a-f]{40}$/.test(latest.commit)) {
1640
- return { resolved: false, reason: "unknown ledger entry carries no usable commit hash" };
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);