muse-crew 0.14.4 → 0.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/API.md +61 -7
- package/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +244 -1
- package/docs/guide.md +42 -6
- package/docs/publish-unknown-recovery.md +28 -113
- package/docs/publish-verification.md +148 -445
- package/docs/release-integrity.md +5 -3
- package/docs/reviews/critic-0145.md +106 -0
- package/lib/AGENTS.md +4 -8
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +113 -15
- package/lib/crew-api.js +855 -725
- package/lib/publish-note-vocabulary.js +137 -24
- package/lib/schema.sql +19 -0
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +65 -65
- package/seed/crons.json +12 -0
- package/workflows/bugfix.js +146 -740
- package/workflows/chore.js +146 -740
- package/workflows/crew-dispatch.js +36 -0
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/standard.js +146 -763
- package/lib/build-readback-request.js +0 -140
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -405
- package/lib/verify-publish.js +0 -414
package/lib/crew-api.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// create-project, update-project, delete-project, list-projects,
|
|
17
17
|
// get-project, kill-switch, get-config, update-config,
|
|
18
18
|
// set-provenance, get-provenance, acknowledge-poll,
|
|
19
|
-
// scan-
|
|
20
|
-
//
|
|
19
|
+
// scan-publish-intent, scan-ack-pending,
|
|
20
|
+
// record-intent-issuance, record-intent-unissuable,
|
|
21
|
+
// record-builder-report, record-version-ack, record-publish-note,
|
|
21
22
|
// record-phase (composite), migrate (one-time import)
|
|
22
23
|
//
|
|
23
24
|
// Output is JSON on stdout. Errors are JSON on stderr.
|
|
@@ -36,7 +37,7 @@ import { classifySurface } from "./classify-surface.js";
|
|
|
36
37
|
// never as unrecognized. Pin-adjacent: every runtime-relative dependency
|
|
37
38
|
// of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
|
|
38
39
|
// mechanically verified by tests/pin-closure.test.js.
|
|
39
|
-
import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS } from "./publish-note-vocabulary.js";
|
|
40
|
+
import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS, assertWritablePublishNote, assertCodeWritablePublishNote } from "./publish-note-vocabulary.js";
|
|
40
41
|
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// Errors
|
|
@@ -1425,6 +1426,17 @@ commands["log-event"] = (db, args) => {
|
|
|
1425
1426
|
if (!valid.includes(args.type)) throw usageError(`bad event type: ${args.type}.`);
|
|
1426
1427
|
const message = (args.message ?? "").trim();
|
|
1427
1428
|
if (message.length < 1 || message.length > 1000) throw usageError("message is required (1-1000 chars).");
|
|
1429
|
+
// (2026-09-20 REVIEW) State-carrying `publish: <verb>` notes are a closed
|
|
1430
|
+
// set: they are read by the ack/intent scans from the events table, so a
|
|
1431
|
+
// prose log-event with type "note" would bypass the vocabulary guard.
|
|
1432
|
+
// Route them through `record-publish-note` (prose) or the guarded code
|
|
1433
|
+
// writer — never log-event.
|
|
1434
|
+
if (args.type === "note" && /publish:\s*[a-z0-9-]+/.test(message)) {
|
|
1435
|
+
throw usageError(
|
|
1436
|
+
"state-carrying publish notes are closed-vocabulary (lib/publish-note-vocabulary.js) — " +
|
|
1437
|
+
"write them via the `record-publish-note` command, not log-event."
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1428
1440
|
if (args.task_id) requireTask(db, args.task_id);
|
|
1429
1441
|
const event = {
|
|
1430
1442
|
id: uuid(), type: args.type, task_id: args.task_id ?? null,
|
|
@@ -1445,36 +1457,15 @@ commands["get-events"] = (db, args) => {
|
|
|
1445
1457
|
return { events: rows.map(mapEvent) };
|
|
1446
1458
|
};
|
|
1447
1459
|
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
// Finds parked tasks whose latest parent publish note is
|
|
1451
|
-
// "publish: verification-requested" (the publisher parks instead of
|
|
1452
|
-
// stamping, per docs/publish-verification.md) with no terminal parent
|
|
1453
|
-
// verdict and no unexpired verification claim. Each returned task is
|
|
1454
|
-
// ATOMICALLY claimed by logging a "publish: verification-claimed"
|
|
1455
|
-
// note-event with a lease expiry, so two concurrent scanners (or a
|
|
1456
|
-
// retrying tick) cannot start a second verification for the same task.
|
|
1457
|
-
// The claim is a note, not a state change: the task stays parked, so the
|
|
1458
|
-
// dispatcher never dispatches QA mid-verification.
|
|
1460
|
+
// Active-release resolution helpers (2026-09-18, room #15).
|
|
1459
1461
|
//
|
|
1460
|
-
// Also reconciles the verified-but-still-parked gap: a parked task whose
|
|
1461
|
-
// latest parent verdict is "publish: verified" is re-queued to in_progress
|
|
1462
|
-
// (the verdict was recorded but the re-queue was lost to a crash).
|
|
1463
|
-
// Failure verdicts are left parked for human attention (fail-closed).
|
|
1464
|
-
//
|
|
1465
|
-
// Returns { to_verify: [...], reconciled: [...] }.
|
|
1466
|
-
// Each to_verify entry: { task_id, commit, build_agent_id, project_id,
|
|
1467
|
-
// repo_path, deploy_slug, crew_release, claim_expires_at }.
|
|
1468
|
-
//
|
|
1469
|
-
// crew_release is resolved through the crew home's `current` symlink (the
|
|
1470
|
-
// immutable active release the release manager maintains) and cross-checked
|
|
1471
|
-
// against this code's own realpath (the release it is actually running
|
|
1472
|
-
// from). Unresolvable or disputed => throw fail-closed BEFORE the claim
|
|
1473
|
-
// transaction: provenance is never stamped with an unknown release.
|
|
1474
|
-
//
|
|
1475
|
-
// Active-release resolution is split in two (room #15, 2026-09-18):
|
|
1476
1462
|
// resolveActiveReleaseName (symlink only — for writers) below, and
|
|
1477
1463
|
// resolveActiveRelease (symlink + self-path cross-check — for verifiers).
|
|
1464
|
+
// The old "Parent publish verification: scan + atomic claim + reconcile"
|
|
1465
|
+
// block (scan-verification-pending, publish: verification-requested /
|
|
1466
|
+
// verification-claimed / verified) was deleted with the 0.14.6
|
|
1467
|
+
// version-acknowledgement rewrite — these helpers now serve the ack scan's
|
|
1468
|
+
// provenance writers only.
|
|
1478
1469
|
function resolveActiveReleaseName(crewHome) {
|
|
1479
1470
|
let linkTarget;
|
|
1480
1471
|
try {
|
|
@@ -1486,240 +1477,45 @@ function resolveActiveReleaseName(crewHome) {
|
|
|
1486
1477
|
}
|
|
1487
1478
|
return basename(resolve(crewHome, linkTarget));
|
|
1488
1479
|
}
|
|
1489
|
-
// The strict resolver for
|
|
1490
|
-
// answer PLUS the self-path cross-check —
|
|
1491
|
-
// the active release's own crew-api.js
|
|
1492
|
-
// Stale cron bodies and mid-deploy
|
|
1480
|
+
// The strict resolver for provenance writers (scan-ack-pending and
|
|
1481
|
+
// record-version-ack): the symlink answer PLUS the self-path cross-check —
|
|
1482
|
+
// the running code must provably be the active release's own crew-api.js
|
|
1483
|
+
// before it stamps acknowledgement claims. Stale cron bodies and mid-deploy
|
|
1484
|
+
// swaps throw fail-closed here.
|
|
1493
1485
|
function resolveActiveRelease(crewHome) {
|
|
1494
1486
|
const activeRelease = resolveActiveReleaseName(crewHome);
|
|
1495
1487
|
const selfPath = new URL(import.meta.url).pathname;
|
|
1496
1488
|
const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
|
|
1497
1489
|
if (!selfMatch) {
|
|
1498
1490
|
throw usageError(
|
|
1499
|
-
`scan
|
|
1491
|
+
`ack/intent scan: running code is not inside a releases/<name>/lib path (${selfPath}). Refusing to stamp.`
|
|
1500
1492
|
);
|
|
1501
1493
|
}
|
|
1502
1494
|
if (selfMatch[1] !== activeRelease) {
|
|
1503
1495
|
throw usageError(
|
|
1504
|
-
`scan
|
|
1496
|
+
`ack/intent scan: running release ${selfMatch[1]} != active release ${activeRelease} (stale cron body or mid-deploy). Refusing to stamp.`
|
|
1505
1497
|
);
|
|
1506
1498
|
}
|
|
1507
1499
|
return activeRelease;
|
|
1508
1500
|
}
|
|
1509
|
-
commands["scan-verification-pending"] = (db, args, ctx) => {
|
|
1510
|
-
const CLAIM_LEASE_MS = 60 * 60 * 1000; // 1 hour
|
|
1511
|
-
const nowMs = Date.now();
|
|
1512
|
-
const claimExpiry = new Date(nowMs + CLAIM_LEASE_MS).toISOString();
|
|
1513
|
-
const releaseName = resolveActiveRelease(ctx.crewHome);
|
|
1514
|
-
|
|
1515
|
-
const parked = db.prepare(
|
|
1516
|
-
`SELECT t.id AS task_id, t.project AS project_id,
|
|
1517
|
-
p.repo_path AS repo_path, p.deploy_slug AS deploy_slug
|
|
1518
|
-
FROM tasks t LEFT JOIN projects p ON p.id = t.project
|
|
1519
|
-
WHERE t.state = 'parked'`
|
|
1520
|
-
).all();
|
|
1521
|
-
|
|
1522
|
-
const toVerify = [];
|
|
1523
|
-
const reconciled = [];
|
|
1524
|
-
const deferred = []; // not-before in the future (unknown-recovery retry) — claimed by a later tick
|
|
1525
|
-
|
|
1526
|
-
db.exec("BEGIN");
|
|
1527
|
-
try {
|
|
1528
|
-
for (const row of parked) {
|
|
1529
|
-
const notes = db.prepare(
|
|
1530
|
-
`SELECT message, timestamp FROM events
|
|
1531
|
-
WHERE task_id = ? AND type = 'note' AND message LIKE '%publish:%'
|
|
1532
|
-
ORDER BY timestamp DESC LIMIT 20`
|
|
1533
|
-
).all(row.task_id);
|
|
1534
|
-
if (notes.length === 0) continue;
|
|
1535
|
-
const latest = notes[0].message;
|
|
1536
|
-
|
|
1537
|
-
// Reconcile first: verdict recorded but the re-queue was lost to a
|
|
1538
|
-
// crash (verified-but-still-parked). Failure verdicts stay parked.
|
|
1539
|
-
// Contained-string matching: the workflow's parkTask prepends
|
|
1540
|
-
// "Parked: " to the reason, so a verification request is stored as
|
|
1541
|
-
// "Parked: publish: verification-requested ..." (observed 2026-09-14).
|
|
1542
|
-
// Prefix anchors would never match; see tests/verify-publish.test.js.
|
|
1543
|
-
if (latest.includes("publish: verified")) {
|
|
1544
|
-
db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?")
|
|
1545
|
-
.run(now(), row.task_id);
|
|
1546
|
-
db.prepare(
|
|
1547
|
-
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1548
|
-
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
1549
|
-
).run(uuid(), row.task_id,
|
|
1550
|
-
"publish: reconciled verified-but-parked -> in_progress (re-queue lost to a crash)",
|
|
1551
|
-
now());
|
|
1552
|
-
reconciled.push({ task_id: row.task_id });
|
|
1553
|
-
continue;
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// Terminal parent verdicts (anything the parent decided, except a
|
|
1557
|
-
// fresh request, a procedural error, or an active claim) — never re-verify.
|
|
1558
|
-
const isRequest = latest.includes("publish: verification-requested");
|
|
1559
|
-
const isClaim = latest.includes("publish: verification-claimed");
|
|
1560
|
-
// Procedural errors (worker botched the procedure, not a content
|
|
1561
|
-
// failure) are retryable — treat like a fresh request. Content
|
|
1562
|
-
// failures (verification-failed) stay terminal.
|
|
1563
|
-
const isProceduralError = latest.includes("publish: verification-procedural-error");
|
|
1564
|
-
if (!isRequest && !isClaim && !isProceduralError) continue; // failed / blocked / mismatch: terminal, fail-closed
|
|
1565
|
-
|
|
1566
|
-
if (isClaim) {
|
|
1567
|
-
// Unexpired claim -> another verifier owns it. Expired -> re-claim below.
|
|
1568
|
-
const m = latest.match(/publish: verification-claimed (\S+)/);
|
|
1569
|
-
if (m && Date.parse(m[1]) > nowMs) continue;
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
// Candidate: find the original verification-requested note to extract
|
|
1573
|
-
// the commit and build agent id (the latest note may be an expired claim
|
|
1574
|
-
// or a procedural error).
|
|
1575
|
-
const req = notes.find((n) => n.message.includes("publish: verification-requested"));
|
|
1576
|
-
if (!req) continue;
|
|
1577
|
-
const commitMatch = req.message.match(/verification-requested ([0-9a-f]{40})/);
|
|
1578
|
-
// Fallback: procedural error note carries the commit too.
|
|
1579
|
-
const procMatch = !commitMatch ? latest.match(/verification-procedural-error ([0-9a-f]{40})/) : null;
|
|
1580
|
-
const commit = commitMatch ? commitMatch[1] : (procMatch ? procMatch[1] : null);
|
|
1581
|
-
const buildMatch = req.message.match(/\(build ([0-9a-f-]{36})\)/);
|
|
1582
|
-
// Attempt binding (2026-09-18, blocker 4): the verifier must select the
|
|
1583
|
-
// submitted ledger entry for the exact (task_id, commit, attempt) —
|
|
1584
|
-
// not just (task_id, commit) — or a retry's trigger is indistinguishable
|
|
1585
|
-
// from the original's.
|
|
1586
|
-
const attemptMatch = req.message.match(/attempt=([^\s]+)/);
|
|
1587
|
-
const attempt = attemptMatch ? attemptMatch[1] : null;
|
|
1588
|
-
if (!commit) continue; // malformed request: cannot verify mechanically, leave parked
|
|
1589
|
-
// not-before (2026-09-18, blocker 15): the unknown-recovery retry
|
|
1590
|
-
// protocol mirrors verification-requested with a not-before so the
|
|
1591
|
-
// re-triggered build gets 20 minutes to land before the verification
|
|
1592
|
-
// scan may claim it. Absent = verify immediately (backward compatible).
|
|
1593
|
-
const nbMatch = req.message.match(/not-before=([0-9T:.\-Z+]+)/);
|
|
1594
|
-
const notBeforeMs = nbMatch ? Date.parse(nbMatch[1]) : NaN;
|
|
1595
|
-
if (notBeforeMs > nowMs) {
|
|
1596
|
-
deferred.push({ task_id: row.task_id, commit, not_before: nbMatch[1] });
|
|
1597
|
-
continue;
|
|
1598
|
-
}
|
|
1599
|
-
db.prepare(
|
|
1600
|
-
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1601
|
-
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
1602
|
-
).run(uuid(), row.task_id, `publish: verification-claimed ${claimExpiry}`, now());
|
|
1603
|
-
toVerify.push({
|
|
1604
|
-
task_id: row.task_id,
|
|
1605
|
-
commit: commit,
|
|
1606
|
-
attempt: attempt,
|
|
1607
|
-
build_agent_id: buildMatch ? buildMatch[1] : null,
|
|
1608
|
-
project_id: row.project_id,
|
|
1609
|
-
repo_path: row.repo_path,
|
|
1610
|
-
deploy_slug: row.deploy_slug,
|
|
1611
|
-
crew_release: releaseName,
|
|
1612
|
-
claim_expires_at: claimExpiry,
|
|
1613
|
-
});
|
|
1614
|
-
}
|
|
1615
|
-
db.exec("COMMIT");
|
|
1616
|
-
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1617
|
-
return { to_verify: toVerify, reconciled, deferred };
|
|
1618
|
-
};
|
|
1619
|
-
|
|
1620
|
-
// Recovery contract for publish attempts parked with an UNKNOWN outcome
|
|
1621
|
-
// (2026-09-14). The structured-output fallback parked the task because no
|
|
1622
|
-
// build could be observed — but the edit may still have gone through (the
|
|
1623
|
-
// in-flight-only fallback could not see a completed build; fixed
|
|
1624
|
-
// in-workflow the same day with the durable audit-dir check). When durable
|
|
1625
|
-
// evidence later shows the build DID complete — a platform audit directory
|
|
1626
|
-
// created inside the publish window (between Integrate completion and the
|
|
1627
|
-
// park event) — this action routes the task to the parent's independent
|
|
1628
|
-
// content verification WITHOUT re-issuing the edit and WITHOUT stamping
|
|
1629
|
-
// provenance. The original unknown ledger entry and park event are
|
|
1630
|
-
// preserved; the resolution is appended to the ledger and the event log.
|
|
1631
|
-
// The parent's read-back (docs/publish-verification.md) remains the real
|
|
1632
|
-
// verification and can still fail terminally. Never: blind retry, manual
|
|
1633
|
-
// stamp, silent state change.
|
|
1634
1501
|
// ---------------------------------------------------------------------------
|
|
1635
|
-
//
|
|
1502
|
+
// Publish helpers (0.14.6 version-acknowledgement architecture).
|
|
1636
1503
|
//
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
// Returns { ok: true, task, slug, repoPath, projectId, commit, attempt,
|
|
1645
|
-
// unknownEntry, unknownTs, ledgerPath, entries }
|
|
1646
|
-
// or { ok: false, reason } — the reason strings are the manual command's
|
|
1647
|
-
// exact messages (pinned by tests/resolve-publish-unknown.test.js).
|
|
1504
|
+
// Publication is a handoff with strong suggestions: the workflow stages a
|
|
1505
|
+
// checksummed diff carrying a per-attempt version; the session-carrying tick
|
|
1506
|
+
// worker issues it via artifact_edit; acknowledgement is the EXACT version
|
|
1507
|
+
// string observed on the artifact's disk or in a version-echoing builder
|
|
1508
|
+
// report. Eric's contract: "If we hear that the artifact acknowledges our
|
|
1509
|
+
// version, that's it. We don't verify against content." Provenance certifies
|
|
1510
|
+
// that an issuance request was ACKNOWLEDGED — never that bytes matched.
|
|
1648
1511
|
// ---------------------------------------------------------------------------
|
|
1649
|
-
function derivePublishUnknownAttempt(db, crewHome, taskId, requireLatest = false) {
|
|
1650
|
-
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
1651
|
-
if (!task) return { ok: false, reason: "unknown task" };
|
|
1652
|
-
if (task.state !== "parked") {
|
|
1653
|
-
return { ok: false, reason: `task is not parked (state=${task.state})` };
|
|
1654
|
-
}
|
|
1655
|
-
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
1656
|
-
const slug = project && project.deploy_slug;
|
|
1657
|
-
if (!slug) return { ok: false, reason: "project has no deploy_slug" };
|
|
1658
|
-
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
1659
|
-
let entries = [];
|
|
1660
|
-
try {
|
|
1661
|
-
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
1662
|
-
.filter((l) => l.trim().length > 0)
|
|
1663
|
-
.map((l) => JSON.parse(l))
|
|
1664
|
-
.filter((e) => e.task_id === taskId);
|
|
1665
|
-
} catch (e) {
|
|
1666
|
-
return { ok: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
1667
|
-
}
|
|
1668
|
-
if (entries.length === 0) return { ok: false, reason: "no publish ledger entries for this task" };
|
|
1669
|
-
// 2026-09-18, should-fix 6: for crash recovery (requireLatest=false),
|
|
1670
|
-
// derive by the most recent unknown attempt, not the ledger tail. Retry
|
|
1671
|
-
// or observability entries appended after the unknown must not break
|
|
1672
|
-
// recovery. For manual resolve (requireLatest=true), the unknown must be
|
|
1673
|
-
// the latest entry.
|
|
1674
|
-
let unknownEntry = null;
|
|
1675
|
-
if (requireLatest) {
|
|
1676
|
-
// Strict mode: latest entry must be unknown.
|
|
1677
|
-
const latest = entries[entries.length - 1];
|
|
1678
|
-
if (latest.outcome === "unknown-resolved") {
|
|
1679
|
-
return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
|
|
1680
|
-
}
|
|
1681
|
-
if (latest.outcome !== "unknown") {
|
|
1682
|
-
return { ok: false, reason: `latest ledger outcome is '${latest.outcome}', not unknown` };
|
|
1683
|
-
}
|
|
1684
|
-
unknownEntry = latest;
|
|
1685
|
-
} else {
|
|
1686
|
-
// Lenient mode: find the most recent unknown, skipping later non-unknown
|
|
1687
|
-
// entries (retry, observability). If an unknown-resolved follows the
|
|
1688
|
-
// unknown, the attempt is resolved.
|
|
1689
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1690
|
-
const e = entries[i];
|
|
1691
|
-
if (e.outcome === "unknown-resolved") {
|
|
1692
|
-
return { ok: false, reason: "already resolved (unknown-resolved ledger entry present)" };
|
|
1693
|
-
}
|
|
1694
|
-
if (e.outcome === "unknown") {
|
|
1695
|
-
unknownEntry = e;
|
|
1696
|
-
break;
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
if (!unknownEntry) {
|
|
1700
|
-
return { ok: false, reason: "no unresolved unknown ledger entry for this task" };
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
if (!unknownEntry.commit || !/^[0-9a-f]{40}$/.test(unknownEntry.commit)) {
|
|
1704
|
-
return { ok: false, reason: "unknown ledger entry carries no usable commit hash" };
|
|
1705
|
-
}
|
|
1706
|
-
return {
|
|
1707
|
-
ok: true, task, slug, repoPath: (project && project.repo_path) || null, projectId: project.id,
|
|
1708
|
-
commit: unknownEntry.commit, attempt: unknownEntry.attempt || null,
|
|
1709
|
-
unknownEntry, unknownTs: unknownEntry.ts || null, ledgerPath, entries,
|
|
1710
|
-
};
|
|
1711
|
-
}
|
|
1712
1512
|
|
|
1713
|
-
// The
|
|
1714
|
-
//
|
|
1715
|
-
|
|
1716
|
-
// marker (composeUnattributedParkReason), so the prefilter must match it
|
|
1717
|
-
// explicitly. LIKE is ASCII case-insensitive in SQLite.
|
|
1718
|
-
const PUBLISH_NOTE_PREFILTER =
|
|
1719
|
-
`(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
|
|
1513
|
+
// The ack/intent scans read the note journal through this prefilter. LIKE
|
|
1514
|
+
// is ASCII case-insensitive in SQLite.
|
|
1515
|
+
const PUBLISH_NOTE_PREFILTER = `(message LIKE '%publish:%')`;
|
|
1720
1516
|
|
|
1721
|
-
// Latest publish: note for a task (the
|
|
1722
|
-
//
|
|
1517
|
+
// Latest publish: note for a task (the publish state machines' state), or
|
|
1518
|
+
// null when the task has no publish notes yet.
|
|
1723
1519
|
function latestPublishNote(db, taskId) {
|
|
1724
1520
|
return db.prepare(
|
|
1725
1521
|
`SELECT message, timestamp FROM events
|
|
@@ -1728,45 +1524,6 @@ function latestPublishNote(db, taskId) {
|
|
|
1728
1524
|
).get(taskId) || null;
|
|
1729
1525
|
}
|
|
1730
1526
|
|
|
1731
|
-
// The trigger anchor for an unknown attempt: the issuance-kind "submitted"
|
|
1732
|
-
// ledger entry for the same attempt (the trigger issuance), falling back to
|
|
1733
|
-
// the oldest by ts for pre-D1 ledgers. The issuance entry binds the trigger
|
|
1734
|
-
// instant — verify-publish.js and the decision doc prefer it the same way;
|
|
1735
|
-
// binding newest misclassifies builds that complete between issuance and
|
|
1736
|
-
// receipt — binding newest misclassifies builds that complete between
|
|
1737
|
-
// issuance and receipt (see docs/reviews/critic-0143.md findings A2/R2/O4).
|
|
1738
|
-
// Returns the entry or null. A null means the trigger
|
|
1739
|
-
// instant is unknowable (or the trigger was never issued) — classification
|
|
1740
|
-
// cannot run; the task stays parked for human attention. Fail closed, never
|
|
1741
|
-
// guess the anchor.
|
|
1742
|
-
function findTriggerEntry(entries, commit, attempt) {
|
|
1743
|
-
// D1 (2026-09-19; entry_kind cut 2026-09-20): the anchor is the OLDEST
|
|
1744
|
-
// submitted entry by ts and the bound instant is its issued_at || ts —
|
|
1745
|
-
// the same fallback verify-publish.js uses. The retired entry_kind
|
|
1746
|
-
// preference filter could never change the bound anchor (issuance and
|
|
1747
|
-
// receipt entries carried the same captured issued_at), so oldest-by-ts
|
|
1748
|
-
// is the whole mechanism.
|
|
1749
|
-
const matches = [];
|
|
1750
|
-
for (let i = 0; i < entries.length; i++) {
|
|
1751
|
-
const e = entries[i];
|
|
1752
|
-
if (e.outcome === "submitted" && e.commit === commit &&
|
|
1753
|
-
(e.attempt || null) === (attempt || null)) {
|
|
1754
|
-
matches.push(e);
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1758
|
-
return matches[0] || null;
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
// The unknown ledger entry's detail says whether the trigger went out.
|
|
1762
|
-
// Unknown parks from preflight/toolcheck inconclusives never issued the
|
|
1763
|
-
// trigger ("the trigger was NOT issued") — there is no dropped edit to
|
|
1764
|
-
// recover, so the unknown-recovery machine must not classify or retry them.
|
|
1765
|
-
function triggerWasIssued(unknownEntry) {
|
|
1766
|
-
const detail = String((unknownEntry && unknownEntry.detail) || "");
|
|
1767
|
-
return !/trigger was NOT issued|no trigger issued/i.test(detail);
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
1527
|
function writePublishNote(db, taskId, message, ts) {
|
|
1771
1528
|
db.prepare(
|
|
1772
1529
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
@@ -1774,199 +1531,229 @@ function writePublishNote(db, taskId, message, ts) {
|
|
|
1774
1531
|
).run(uuid(), taskId, message, ts || now());
|
|
1775
1532
|
}
|
|
1776
1533
|
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
return { resolved: false, reason: "publish window is empty or inverted" };
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
// Audit dirs are named <UTC-timestamp>-<id>; the timestamp is the build's.
|
|
1814
|
-
const auditsDir = join(homedir(), "workspace", "ts-spaces", slug, "audits");
|
|
1815
|
-
let evidenceDirs = [];
|
|
1534
|
+
// 0.14.6 §1.1 — the per-attempt publish version: "<commit>:<attempt>".
|
|
1535
|
+
// A plain version number, not a hash (2026-09-20: the sha256 "nonce"
|
|
1536
|
+
// defended against a forger that doesn't exist — the builder's failure mode
|
|
1537
|
+
// is omission, never forging our receipts — while the artifact's real failure
|
|
1538
|
+
// mode, an unfaithful carrier, defeats a hash and a counter identically. We
|
|
1539
|
+
// own the code, so we count our own attempts; we don't own the artifact, so
|
|
1540
|
+
// verification stays observation of its state, never its attestation).
|
|
1541
|
+
// Deterministic: no wall clock, no randomness. Byte-identical to
|
|
1542
|
+
// lib/compute-publish-diff.js's derivation (which synthesizes the
|
|
1543
|
+
// `.crew-publish-version-<task_id>` receipt into the staged diff), so the
|
|
1544
|
+
// workflow's staging, the tick's regeneration, and these scans all agree on
|
|
1545
|
+
// the string. The commit binds the version to the exact code (a stale receipt
|
|
1546
|
+
// from an earlier commit can never equal a fresh version); the attempt
|
|
1547
|
+
// counts re-issues of that commit. `attempt` is the 1-based publish attempt;
|
|
1548
|
+
// String(attempt) matches the argv string the diff script concatenates.
|
|
1549
|
+
function derivePublishVersion(commit, attempt) {
|
|
1550
|
+
return commit + ":" + String(attempt);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// A well-formed publish version: "<40-hex commit>:<positive-integer attempt>".
|
|
1554
|
+
function isPublishVersion(s) {
|
|
1555
|
+
return typeof s === "string" && /^[0-9a-f]{40}:[1-9][0-9]*$/.test(s);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// 0.14.6 §1.5 — the closed publish-note writer. Every state-carrying
|
|
1559
|
+
// publish-note write by deterministic code in this file goes through
|
|
1560
|
+
// writeGuardedPublishNote, which asserts the verb against the code-writable
|
|
1561
|
+
// registry (transitional + current terminals) in
|
|
1562
|
+
// lib/publish-note-vocabulary.js before writing. Tick prose mints notes
|
|
1563
|
+
// only through the `record-publish-note` command, which asserts the
|
|
1564
|
+
// prose-writable registry (transitional only — prose can never mint a
|
|
1565
|
+
// terminal state). A registry violation is a caller bug (usage).
|
|
1566
|
+
function writeGuardedPublishNote(db, taskId, message, ts) {
|
|
1816
1567
|
try {
|
|
1817
|
-
|
|
1818
|
-
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z-/.exec(name);
|
|
1819
|
-
if (!m) return false;
|
|
1820
|
-
const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
|
|
1821
|
-
return t > winStart && t <= winEnd;
|
|
1822
|
-
}).sort();
|
|
1568
|
+
assertCodeWritablePublishNote(message);
|
|
1823
1569
|
} catch (e) {
|
|
1824
|
-
|
|
1825
|
-
}
|
|
1826
|
-
if (evidenceDirs.length === 0) {
|
|
1827
|
-
return { resolved: false, reason: "no audit build completed inside the publish window — still unknown" };
|
|
1570
|
+
throw usageError(e.message);
|
|
1828
1571
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
// single dir can be attributed to this attempt, so resolution stays
|
|
1832
|
-
// unknown. Only when exactly one dir remains is evidenceDirs[0] safe.
|
|
1833
|
-
if (evidenceDirs.length > 1) {
|
|
1834
|
-
return { resolved: false, reason: `audit-dir ambiguity: ${evidenceDirs.length} audit dirs inside the publish window — still unknown` };
|
|
1835
|
-
}
|
|
1836
|
-
const evidenceDir = evidenceDirs[0];
|
|
1572
|
+
writePublishNote(db, taskId, message, ts);
|
|
1573
|
+
}
|
|
1837
1574
|
|
|
1838
|
-
|
|
1839
|
-
|
|
1575
|
+
// The publish ledger is a per-slug JSONL file under the crew home
|
|
1576
|
+
// ($CREW_HOME/.publish-ledger/<slug>.jsonl): best-effort observability plus
|
|
1577
|
+
// the durable anchors the scans read (intent entries, submitted entries).
|
|
1578
|
+
function readTaskLedgerEntries(crewHome, slug, taskId) {
|
|
1579
|
+
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
1840
1580
|
try {
|
|
1841
|
-
|
|
1842
|
-
|
|
1581
|
+
return {
|
|
1582
|
+
ok: true, ledgerPath,
|
|
1583
|
+
entries: readFileSync(ledgerPath, "utf8").split("\n")
|
|
1584
|
+
.filter((l) => l.trim().length > 0)
|
|
1585
|
+
.map((l) => JSON.parse(l))
|
|
1586
|
+
.filter((e) => e.task_id === taskId),
|
|
1587
|
+
};
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
return { ok: false, ledgerPath, reason: `cannot read publish ledger: ${e.message}` };
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1843
1592
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
).run(uuid(), taskId,
|
|
1854
|
-
`publish: unknown-resolved ${latest.commit} (audit ${evidenceDir}, report ok=${auditOk}) — durable build evidence inside the publish window; routing to parent content verification. No re-trigger issued, no provenance stamped, original unknown outcome preserved.`,
|
|
1855
|
-
ts);
|
|
1856
|
-
// Mirror the workflow's verification-requested park note so
|
|
1857
|
-
// scan-verification-pending claims it on the next tick.
|
|
1858
|
-
db.prepare(
|
|
1859
|
-
"INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
|
|
1860
|
-
).run(uuid(), taskId,
|
|
1861
|
-
`Parked: publish: verification-requested ${latest.commit} (build agent_id unobserved) — recovered from publish-unknown via resolve-publish-unknown; durable evidence ${evidenceDir}. waiting on the manual read-back in docs/publish-verification.md.`,
|
|
1862
|
-
tsLater);
|
|
1863
|
-
db.prepare("UPDATE tasks SET state = 'parked', updated_at = ? WHERE id = ?").run(tsLater, taskId);
|
|
1864
|
-
db.exec("COMMIT");
|
|
1865
|
-
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1593
|
+
// Latest publish-intent ledger entry for (task, commit, publish_attempt):
|
|
1594
|
+
// the issuance anchor. The workflow writes attempt 1 (issuer "workflow");
|
|
1595
|
+
// the ack scan's re-issue writes attempts 2+ (issuer "tick-worker").
|
|
1596
|
+
function findIntentEntry(entries, commit, publishAttempt) {
|
|
1597
|
+
const matches = entries.filter((e) => e.outcome === "publish-intent" &&
|
|
1598
|
+
e.commit === commit && (e.publish_attempt || null) === (publishAttempt || null));
|
|
1599
|
+
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1600
|
+
return matches[matches.length - 1] || null;
|
|
1601
|
+
}
|
|
1866
1602
|
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
agent_id: null,
|
|
1878
|
-
applied_report: null,
|
|
1879
|
-
outcome: "unknown-resolved",
|
|
1880
|
-
detail: `durable build evidence ${evidenceDir} (audit report ok=${auditOk}) inside publish window; routed to parent verification without re-trigger`,
|
|
1881
|
-
}) + "\n");
|
|
1882
|
-
} catch (e) { /* best-effort observability */ }
|
|
1883
|
-
|
|
1884
|
-
return { resolved: true, task_id: taskId, commit: latest.commit, evidence_dir: evidenceDir, audit_report_ok: auditOk };
|
|
1885
|
-
};
|
|
1603
|
+
// Issuer-written "submitted" entries for (task, commit): the issuance
|
|
1604
|
+
// record. Only the session-carrying tick worker performs issuance, and it
|
|
1605
|
+
// stamps its writes issuer="tick-worker" — anything else is a phantom claim
|
|
1606
|
+
// and never binds. Oldest first (D1: the bound instant is issued_at || ts).
|
|
1607
|
+
function findSubmittedEntries(entries, commit) {
|
|
1608
|
+
const matches = entries.filter((e) => e.outcome === "submitted" &&
|
|
1609
|
+
e.issuer === "tick-worker" && e.commit === commit);
|
|
1610
|
+
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1611
|
+
return matches;
|
|
1612
|
+
}
|
|
1886
1613
|
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
// the
|
|
1890
|
-
// the
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1614
|
+
// 0.14.6 §1.3 — record a positive version acknowledgement: stamp provenance
|
|
1615
|
+
// (certifying the issuance REQUEST was acknowledged — never byte equality),
|
|
1616
|
+
// write the terminal `publish: version-acknowledged` note, and re-queue the
|
|
1617
|
+
// task. Shared by scan-ack-pending and the record-version-ack command (the
|
|
1618
|
+
// re-claim path). The strict release cross-check refuses to stamp from a
|
|
1619
|
+
// stale cron body or a mid-deploy swap — fail closed.
|
|
1620
|
+
function recordVersionAck(db, ctx, { taskId, projectId, commit, version, via }) {
|
|
1621
|
+
const releaseName = resolveActiveRelease(ctx.crewHome);
|
|
1622
|
+
const ts = now();
|
|
1623
|
+
stampProvenance(db, {
|
|
1624
|
+
project_id: projectId, source_commit: commit, crew_release: releaseName,
|
|
1625
|
+
published_at: ts, task_id: taskId,
|
|
1626
|
+
});
|
|
1627
|
+
writeGuardedPublishNote(db, taskId,
|
|
1628
|
+
`publish: version-acknowledged ${commit} via=${via} — the artifact acknowledged this attempt's exact version ` +
|
|
1629
|
+
`(${via === "disk" ? "on-disk receipt .crew-publish-version" : "version-echoing builder report"}); ` +
|
|
1630
|
+
`provenance stamped for crew release ${releaseName} (certifies the issuance request was acknowledged, not byte equality). Task re-queued.`,
|
|
1631
|
+
ts);
|
|
1632
|
+
db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?").run(ts, taskId);
|
|
1633
|
+
return { ok: true, task_id: taskId, commit, version, via, crew_release: releaseName };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// The version on a submitted entry must be the deterministic one for its
|
|
1637
|
+
// (task, commit, publish_attempt) — the ledger is advisory; the derivation
|
|
1638
|
+
// is the contract. Returns the entry's publish_attempt or null.
|
|
1639
|
+
function verifiedSubmittedAttempt(taskId, entry) {
|
|
1640
|
+
const pa = entry.publish_attempt;
|
|
1641
|
+
if (!Number.isInteger(pa) || pa < 1) return null;
|
|
1642
|
+
if (!/^[0-9a-f]{40}$/.test(entry.commit || "")) return null;
|
|
1643
|
+
if (!isPublishVersion(entry.version)) return null;
|
|
1644
|
+
if (entry.version !== derivePublishVersion(entry.commit, pa)) return null;
|
|
1645
|
+
return pa;
|
|
1646
|
+
}
|
|
1647
|
+
// Publish-intent scan (0.14.6 §1.2/§1.3): claims parked publish intents for
|
|
1648
|
+
// the session-carrying tick worker's direct issuance. One atomic
|
|
1649
|
+
// transaction per scan: claims and terminal notes all land together or not
|
|
1650
|
+
// at all.
|
|
1896
1651
|
//
|
|
1897
1652
|
// latest publish: note this scan
|
|
1898
|
-
// "
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
// lib/publish-note-vocabulary.js — the reason named, never
|
|
1911
|
-
// scan names the recognized note) "unrecognized"
|
|
1912
|
-
// "publish: verification-requested" → already routed; ignored (the verification scan owns it)
|
|
1653
|
+
// "publish: publish-requested …" → atomic 1h claim → intent bucket:
|
|
1654
|
+
// the tick issues the edit directly
|
|
1655
|
+
// in its own turn (no child).
|
|
1656
|
+
// "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it);
|
|
1657
|
+
// expired: re-claim → intent (the tick
|
|
1658
|
+
// checks the dead attempt's version
|
|
1659
|
+
// receipt before issuing — never
|
|
1660
|
+
// blindly re-issues).
|
|
1661
|
+
// "publish: edit-issued …" → already routed; ignored (the ack
|
|
1662
|
+
// scan owns it from here).
|
|
1663
|
+
// terminal notes (the closed registry) → terminal; skipped with the
|
|
1664
|
+
// reason named, never "unrecognized".
|
|
1913
1665
|
//
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
1916
|
-
|
|
1917
|
-
// Fail closed, never guess.
|
|
1918
|
-
commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
1666
|
+
// The tick never issues without a claim, and a claim never outlives its
|
|
1667
|
+
// lease. Fail closed, never guess.
|
|
1668
|
+
commands["scan-publish-intent"] = (db, args, ctx) => {
|
|
1919
1669
|
const crewHome = ctx.crewHome;
|
|
1920
1670
|
const nowMs = Date.now();
|
|
1921
|
-
const quiesceMs = 30 * 60 * 1000; // design §1.4e: never trust a read before the park is 30m old
|
|
1922
1671
|
const claimLeaseMs = 60 * 60 * 1000; // one hour — survives a dead tick, does not block the next one
|
|
1923
|
-
const retrySettleMs = 20 * 60 * 1000; // a re-triggered build gets 20m before verification may claim it
|
|
1924
1672
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1925
1673
|
|
|
1926
|
-
const
|
|
1927
|
-
const due = [];
|
|
1928
|
-
const retryDue = [];
|
|
1929
|
-
const mirrored = [];
|
|
1674
|
+
const intent = [];
|
|
1930
1675
|
const skipped = [];
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
//
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1676
|
+
const requeued = []; // stale-base intents re-queued to the workflow's Publish for re-preparation
|
|
1677
|
+
|
|
1678
|
+
const skip = (taskId, reason, detail) =>
|
|
1679
|
+
skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
|
|
1680
|
+
|
|
1681
|
+
// One-party publish intent: the workflow parked at intent — the merged
|
|
1682
|
+
// change is staged as a checksummed diff carrying the per-attempt version,
|
|
1683
|
+
// and issuance belongs to the session-carrying tick worker. The intent
|
|
1684
|
+
// ledger entry (outcome "publish-intent") is the durable anchor: the tick
|
|
1685
|
+
// verifies the staged diff's sha256 against it and regenerates from
|
|
1686
|
+
// base..commit when the file is missing. reclaimed=true when a previous
|
|
1687
|
+
// tick's claim expired: the tick checks the dead attempt's version receipt
|
|
1688
|
+
// before issuing, because the dead tick may have issued and died before
|
|
1689
|
+
// recording it — a re-claim never blindly re-issues.
|
|
1690
|
+
const claimForIntent = (taskId, intentNote, reclaimed, anchorNote) => {
|
|
1691
|
+
const m = intentNote.message.match(/publish:\s*publish-requested\s+([0-9a-f]{40})\s+attempt=(\d+)/);
|
|
1692
|
+
if (!m) {
|
|
1693
|
+
skip(taskId, "intent-note-unparseable",
|
|
1694
|
+
"the publish-requested note carries no commit hash + attempt=N — stays parked for human attention");
|
|
1943
1695
|
return;
|
|
1944
1696
|
}
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
if (!
|
|
1949
|
-
skip(taskId, "
|
|
1950
|
-
|
|
1697
|
+
const commit = m[1];
|
|
1698
|
+
const publishAttempt = parseInt(m[2], 10);
|
|
1699
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
1700
|
+
if (!task || task.state !== "parked") {
|
|
1701
|
+
skip(taskId, "not-parked",
|
|
1702
|
+
`task is not parked (state=${task && task.state}) — the intent claim requires a parked task`);
|
|
1951
1703
|
return;
|
|
1952
1704
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
//
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1705
|
+
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
1706
|
+
const slug = project && project.deploy_slug;
|
|
1707
|
+
if (!slug) {
|
|
1708
|
+
skip(taskId, "no-deploy-slug", "project has no deploy_slug — stays parked for human attention");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
// A null repo_path fails closed at emission: the tick can neither
|
|
1712
|
+
// regenerate nor verify the diff. No claim, no tick work.
|
|
1713
|
+
const repoPath = project && project.repo_path;
|
|
1714
|
+
if (!repoPath) {
|
|
1715
|
+
skip(taskId, "no-repo-path",
|
|
1716
|
+
"project has no repo_path — the intent diff cannot be regenerated or verified; fails closed at emission; stays parked for human attention");
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
|
|
1720
|
+
if (!ledger.ok) {
|
|
1721
|
+
skip(taskId, "ledger-unreadable", `${ledger.reason} — stays parked for human attention`);
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const intentEntry = findIntentEntry(ledger.entries, commit, publishAttempt);
|
|
1725
|
+
if (!intentEntry) {
|
|
1726
|
+
skip(taskId, "no-intent-entry",
|
|
1727
|
+
"no publish-intent ledger entry for this task/commit/attempt — the intent anchor is missing; stays parked for human attention");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
// The version is protocol: the ledger is advisory — the deterministic
|
|
1731
|
+
// derivation is the contract. A mismatch means the staged diff was not
|
|
1732
|
+
// built by this machine; never issue it.
|
|
1733
|
+
const expectedVersion = derivePublishVersion(commit, publishAttempt);
|
|
1734
|
+
if (intentEntry.version !== expectedVersion) {
|
|
1735
|
+
skip(taskId, "intent-version-mismatch",
|
|
1736
|
+
"the intent entry's version is missing or does not match the deterministic derivation — stays parked for human attention");
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (typeof intentEntry.diff_path !== "string" || !intentEntry.diff_path ||
|
|
1740
|
+
!/^[0-9a-f]{64}$/.test(intentEntry.diff_sha256 || "")) {
|
|
1741
|
+
skip(taskId, "intent-diff-unusable",
|
|
1742
|
+
"the intent entry carries no usable diff_path/diff_sha256 — stays parked for human attention");
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
// Freshness CAS: the latest publish: note must still be the anchor note
|
|
1746
|
+
// — for a first claim that is the intent note; for a re-claim it is the
|
|
1747
|
+
// expired claim note.
|
|
1748
|
+
const fresh = latestPublishNote(db, taskId);
|
|
1749
|
+
if (!fresh || fresh.message !== anchorNote.message) { skip(taskId, "raced"); return; }
|
|
1750
|
+
// The classifier-base convention: last acknowledged publish, or the
|
|
1751
|
+
// empty tree on a first publish — the tick needs it to regenerate the
|
|
1752
|
+
// diff byte-identically when the staged file is missing. A query error
|
|
1753
|
+
// is not "no provenance" — fail closed.
|
|
1967
1754
|
let base;
|
|
1968
1755
|
try {
|
|
1969
|
-
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(
|
|
1756
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
1970
1757
|
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
|
|
1971
1758
|
base = prov.provenance_source_commit;
|
|
1972
1759
|
} else {
|
|
@@ -1977,18 +1764,40 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
1977
1764
|
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
1978
1765
|
return;
|
|
1979
1766
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1767
|
+
// The intent's recorded base must match stamped provenance: another
|
|
1768
|
+
// publish may have landed after the intent was staged. A stale base
|
|
1769
|
+
// means the staged diff is against a superseded base — re-queue the
|
|
1770
|
+
// task (next_phase routes it straight back to the workflow's Publish,
|
|
1771
|
+
// which re-prepares the intent against the new base); never issue the
|
|
1772
|
+
// stale diff. Intents with no recorded base re-queue the same way.
|
|
1773
|
+
const intentBase = (typeof intentEntry.base === "string" && /^[0-9a-f]{40}$/.test(intentEntry.base))
|
|
1774
|
+
? intentEntry.base : null;
|
|
1775
|
+
if (!intentBase || intentBase !== base) {
|
|
1776
|
+
const staleAt = new Date(nowMs).toISOString();
|
|
1777
|
+
writeGuardedPublishNote(db, taskId,
|
|
1778
|
+
`publish: publish-base-stale ${commit} — the intent's staged diff was computed against base ` +
|
|
1779
|
+
`${intentBase ? intentBase.slice(0, 7) : "(none recorded)"} but stamped provenance now reads ` +
|
|
1780
|
+
`${base.slice(0, 7)}; issuing the stale diff risks a spurious mismatch. Re-queued: ` +
|
|
1781
|
+
`the workflow's Publish re-prepares the intent against the new base.`,
|
|
1782
|
+
staleAt);
|
|
1783
|
+
db.prepare("UPDATE tasks SET next_phase = 'Publish', state = 'in_progress', updated_at = ? WHERE id = ?")
|
|
1784
|
+
.run(now(), taskId);
|
|
1785
|
+
requeued.push({ task_id: taskId, intent_base: intentBase, provenance_base: base });
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const claimedAt = new Date(nowMs).toISOString();
|
|
1789
|
+
const expiry = new Date(nowMs + claimLeaseMs).toISOString();
|
|
1790
|
+
writeGuardedPublishNote(db, taskId,
|
|
1791
|
+
`publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit} ` +
|
|
1792
|
+
`(attempt ${publishAttempt}); claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
|
|
1793
|
+
claimedAt);
|
|
1794
|
+
intent.push({
|
|
1795
|
+
task_id: taskId, commit, attempt: publishAttempt, version: expectedVersion,
|
|
1796
|
+
slug, repo_path: repoPath,
|
|
1797
|
+
project_id: project.id, ledger_path: ledger.ledgerPath, base,
|
|
1798
|
+
diff_path: intentEntry.diff_path, diff_sha256: intentEntry.diff_sha256,
|
|
1799
|
+
files: Array.isArray(intentEntry.files) ? intentEntry.files : null,
|
|
1800
|
+
claimed_at: claimedAt, claim_expiry: expiry, reclaimed,
|
|
1992
1801
|
});
|
|
1993
1802
|
};
|
|
1994
1803
|
|
|
@@ -2007,315 +1816,636 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2007
1816
|
const latest = notes[0];
|
|
2008
1817
|
const msg = latest.message;
|
|
2009
1818
|
const has = (s) => msg.includes(s);
|
|
2010
|
-
const historyHas = (s) => notes.some((n) => n.message.includes(s));
|
|
2011
1819
|
|
|
2012
|
-
// Terminal states — the machine never revisits these. The vocabulary
|
|
2013
|
-
// explicit state (lib/publish-note-vocabulary.js); a recognized
|
|
2014
|
-
// note is skipped as terminal with its meaning, never as
|
|
1820
|
+
// Terminal states — the machine never revisits these. The vocabulary
|
|
1821
|
+
// is explicit state (lib/publish-note-vocabulary.js); a recognized
|
|
1822
|
+
// terminal note is skipped as terminal with its meaning, never as
|
|
1823
|
+
// unrecognized.
|
|
2015
1824
|
const terminalNote = matchTerminalPublishNote(msg);
|
|
2016
1825
|
if (terminalNote) {
|
|
2017
1826
|
skip(taskId, "terminal",
|
|
2018
1827
|
`recognized terminal note '${terminalNote}' — ${TERMINAL_NOTE_MEANINGS[terminalNote]}`);
|
|
2019
1828
|
continue;
|
|
2020
1829
|
}
|
|
2021
|
-
// Already
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
skip(taskId, "routed-to-verification");
|
|
2025
|
-
continue;
|
|
2026
|
-
}
|
|
2027
|
-
// A retry went out but the mirrored verification request is missing
|
|
2028
|
-
// (the tick died between the two writes). Mirror it now — the retry is
|
|
2029
|
-
// at least one tick old, so no not-before is needed.
|
|
2030
|
-
if (has("publish: retry-issued")) {
|
|
2031
|
-
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2032
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2033
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2034
|
-
writePublishNote(db, taskId,
|
|
2035
|
-
`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.`);
|
|
2036
|
-
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: null, reason: "retry-issued-without-mirror" });
|
|
2037
|
-
continue;
|
|
2038
|
-
}
|
|
2039
|
-
// A retry-intended note with no retry-issued: the tick died mid-retry
|
|
2040
|
-
// and the trigger may or may not have gone out. NEVER re-trigger here —
|
|
2041
|
-
// route to the verification pipeline and let content verification be
|
|
2042
|
-
// the arbiter (design §1.5 R3).
|
|
2043
|
-
if (has("publish: retry-intended")) {
|
|
2044
|
-
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2045
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2046
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2047
|
-
const m = msg.match(/publish: retry-intended\s+[0-9a-f]{40}\s+(\S+)/);
|
|
2048
|
-
const intendedMs = m ? Date.parse(m[1]) : NaN;
|
|
2049
|
-
const notBefore = new Date(Math.max(nowMs, (intendedMs > 0 ? intendedMs : nowMs) + retrySettleMs)).toISOString();
|
|
2050
|
-
writePublishNote(db, taskId,
|
|
2051
|
-
`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.`);
|
|
2052
|
-
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: notBefore, reason: "retry-intended-without-retry-issued" });
|
|
1830
|
+
// Already issued — the acknowledgement scan owns the task from here.
|
|
1831
|
+
if (has("publish: edit-issued")) {
|
|
1832
|
+
skip(taskId, "routed-to-ack-scan");
|
|
2053
1833
|
continue;
|
|
2054
1834
|
}
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
const
|
|
2062
|
-
if (
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
try {
|
|
2067
|
-
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2068
|
-
ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2069
|
-
commit: derived.commit, attempt: derived.attempt, agent_id: null,
|
|
2070
|
-
applied_report: null, outcome: "unknown-classified",
|
|
2071
|
-
detail: "retry budget exhausted — ambiguous, terminal",
|
|
2072
|
-
}) + "\n");
|
|
2073
|
-
} catch (e) { /* best-effort observability */ }
|
|
1835
|
+
// A previous tick claimed this intent. Unexpired: a tick owns it.
|
|
1836
|
+
// Expired: the tick died mid-issuance — re-derive from the original
|
|
1837
|
+
// intent note and re-claim; the re-claiming tick checks the dead
|
|
1838
|
+
// attempt's version receipt before issuing, never blindly re-issues.
|
|
1839
|
+
if (has("publish: publish-intent-claimed")) {
|
|
1840
|
+
const cm = msg.match(/publish: publish-intent-claimed\s+(\S+)/);
|
|
1841
|
+
const expiryMs = cm ? Date.parse(cm[1]) : NaN;
|
|
1842
|
+
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
1843
|
+
const intentNote = notes.find((n) => n.message.includes("publish: publish-requested"));
|
|
1844
|
+
if (!intentNote) {
|
|
1845
|
+
skip(taskId, "no-intent-note", "expired intent claim with no publish-requested note in history");
|
|
2074
1846
|
continue;
|
|
2075
1847
|
}
|
|
2076
|
-
// Retry budget free: queue the retry protocol. The tick needs the
|
|
2077
|
-
// classifier base to regenerate the publish diff byte-identically
|
|
2078
|
-
// (compute-publish-diff.js --base), so ferry it from provenance.
|
|
2079
|
-
// The in-lock re-check also needs the trigger anchor and the
|
|
2080
|
-
// pre-trigger manifest baseline (design §1.9).
|
|
2081
|
-
const retryProv = db.prepare(
|
|
2082
|
-
`SELECT provenance_source_commit FROM projects WHERE id = ?`
|
|
2083
|
-
).get(derived.projectId);
|
|
2084
|
-
const retryBase = (retryProv && retryProv.provenance_source_commit) || EMPTY_TREE;
|
|
2085
|
-
const retryTrigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
|
|
2086
|
-
retryDue.push({
|
|
2087
|
-
task_id: taskId, commit: derived.commit, attempt: derived.attempt,
|
|
2088
|
-
slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
|
|
2089
|
-
ledger_path: derived.ledgerPath, base: retryBase,
|
|
2090
|
-
trigger_ts: (retryTrigger && (retryTrigger.issued_at || retryTrigger.ts)) || null,
|
|
2091
|
-
manifest_before: (retryTrigger && retryTrigger.manifest_before &&
|
|
2092
|
-
typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
|
|
2093
|
-
});
|
|
2094
|
-
continue;
|
|
2095
|
-
}
|
|
2096
|
-
// A previous tick claimed this task for classification.
|
|
2097
|
-
// A previous tick claimed this task for classification.
|
|
2098
|
-
if (has("publish: unknown-recovery-claimed")) {
|
|
2099
|
-
const m = msg.match(/publish: unknown-recovery-claimed\s+(\S+)/);
|
|
2100
|
-
const expiryMs = m ? Date.parse(m[1]) : NaN;
|
|
2101
|
-
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
2102
|
-
// Expired claim: the tick died mid-classification. Re-derive from
|
|
2103
|
-
// the original unknown note and re-claim.
|
|
2104
|
-
const parkNote = notes.find((n) => /publish outcome unknown/i.test(n.message));
|
|
2105
|
-
if (!parkNote) { skip(taskId, "no-unknown-note", "expired claim with no unknown park note in history"); continue; }
|
|
2106
1848
|
const fresh = latestPublishNote(db, taskId);
|
|
2107
1849
|
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2108
|
-
|
|
1850
|
+
claimForIntent(taskId, intentNote, true, latest);
|
|
2109
1851
|
continue;
|
|
2110
1852
|
}
|
|
2111
|
-
// The
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
const parkMs = Date.parse(latest.timestamp);
|
|
2115
|
-
if (!(parkMs > 0) || nowMs < parkMs + quiesceMs) {
|
|
2116
|
-
waiting.push({ task_id: taskId, parked_at: latest.timestamp });
|
|
2117
|
-
continue;
|
|
2118
|
-
}
|
|
2119
|
-
// Re-check freshness inside the transaction: the latest publish:
|
|
2120
|
-
// note must still be this unknown note.
|
|
2121
|
-
const fresh = latestPublishNote(db, taskId);
|
|
2122
|
-
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2123
|
-
claimForClassification(taskId, notes, latest);
|
|
1853
|
+
// The workflow's parked intent.
|
|
1854
|
+
if (has("publish: publish-requested")) {
|
|
1855
|
+
claimForIntent(taskId, latest, false, latest);
|
|
2124
1856
|
continue;
|
|
2125
1857
|
}
|
|
2126
|
-
skip(taskId, "unrecognized-publish-note"
|
|
1858
|
+
skip(taskId, "unrecognized-publish-note",
|
|
1859
|
+
`latest publish note is not part of the 0.14.6 machine — stays parked for human attention: ${msg.slice(0, 120)}`);
|
|
2127
1860
|
}
|
|
2128
1861
|
db.exec("COMMIT");
|
|
2129
1862
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2130
1863
|
|
|
2131
|
-
return {
|
|
1864
|
+
return { intent, skipped, requeued };
|
|
2132
1865
|
};
|
|
2133
|
-
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
// scan-publish-unknown "due" entry and passes the classifier's JSON here.
|
|
2137
|
-
// Code decides: every note the decision implies is written here, atomically,
|
|
2138
|
-
// never free-formed by the tick.
|
|
1866
|
+
// Record a one-party intent issuance (0.14.6 §1.2): the session-carrying
|
|
1867
|
+
// tick worker calls this exactly once per artifact_edit call that completed
|
|
1868
|
+
// without error and without an explicit refusal.
|
|
2139
1869
|
//
|
|
2140
|
-
// Args: { task_id, claim_expiry,
|
|
2141
|
-
//
|
|
1870
|
+
// Args: { task_id, claim_expiry, edit_outcome: "accepted"|"refused",
|
|
1871
|
+
// refusal_text? } — omit refusal_text unless refused.
|
|
2142
1872
|
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
2145
|
-
//
|
|
2146
|
-
//
|
|
1873
|
+
// `accepted` writes the issuer-stamped `submitted` ledger entry (carrying
|
|
1874
|
+
// the attempt's version) and mirrors `publish: edit-issued` — the ack scan
|
|
1875
|
+
// claims the task on a later tick and evaluates acknowledgement evidence.
|
|
1876
|
+
// `refused` writes `rejected` and the terminal `publish: publish-refused`
|
|
1877
|
+
// note — no re-issue, ever.
|
|
1878
|
+
//
|
|
1879
|
+
// There is deliberately NO outcome for an inconclusive edit (timeout,
|
|
1880
|
+
// transport error, tool unavailable): the tick records NOTHING then — the
|
|
1881
|
+
// claim expires, the next scan re-claims, and the re-claiming tick's version
|
|
1882
|
+
// check decides. An unrecorded outcome is the only honest record of an
|
|
1883
|
+
// unknown one.
|
|
2147
1884
|
//
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
//
|
|
2154
|
-
//
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2157
|
-
//
|
|
2158
|
-
|
|
2159
|
-
//
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
1885
|
+
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
1886
|
+
// latest publish: note is still this tick's intent claim. A stale tick gets
|
|
1887
|
+
// { recorded: false } and writes nothing.
|
|
1888
|
+
// Shared preamble for the tick worker's claim-guarded record commands
|
|
1889
|
+
// (record-intent-issuance, record-intent-unissuable, 2026-09-20 REVIEW):
|
|
1890
|
+
// verifies the CAS claim, resolves the project's deploy slug, reads the
|
|
1891
|
+
// task's publish ledger, and locates the latest publish-intent entry.
|
|
1892
|
+
// Returns { ok: false, reason } when the command must not record, else
|
|
1893
|
+
// { ok: true, slug, ledgerPath, intentEntry, commit }. Callers run inside
|
|
1894
|
+
// their own BEGIN/COMMIT transaction and ROLLBACK on { ok: false }.
|
|
1895
|
+
function resolveClaimedIntent(db, ctx, taskId, claimExpiry) {
|
|
1896
|
+
// CAS: the latest publish: note must still be this tick's claim.
|
|
1897
|
+
const latest = latestPublishNote(db, taskId);
|
|
1898
|
+
if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
|
|
1899
|
+
!latest.message.includes(claimExpiry)) {
|
|
1900
|
+
return { ok: false, reason: "claim no longer current — another tick owns this task" };
|
|
1901
|
+
}
|
|
1902
|
+
const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
|
|
1903
|
+
const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
1904
|
+
const slug = project && project.deploy_slug;
|
|
1905
|
+
if (!slug) return { ok: false, reason: "project has no deploy_slug" };
|
|
1906
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
1907
|
+
if (!ledger.ok) return { ok: false, reason: ledger.reason };
|
|
1908
|
+
const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
1909
|
+
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
1910
|
+
return { ok: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
1911
|
+
}
|
|
1912
|
+
return { ok: true, slug, ledgerPath: ledger.ledgerPath, intentEntry, commit: intentEntry.commit };
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
2163
1916
|
const taskId = args.task_id;
|
|
2164
1917
|
const claimExpiry = args.claim_expiry;
|
|
2165
|
-
const
|
|
1918
|
+
const editOutcome = args.edit_outcome;
|
|
2166
1919
|
if (!taskId) throw usageError("task_id is required.");
|
|
2167
1920
|
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
2168
|
-
if (!
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
if (!valid.includes(verdict)) throw usageError(`unknown classifier decision: ${verdict}.`);
|
|
1921
|
+
if (!["accepted", "refused"].includes(editOutcome)) {
|
|
1922
|
+
throw usageError(`unknown edit outcome: ${editOutcome}.`);
|
|
1923
|
+
}
|
|
2172
1924
|
|
|
2173
1925
|
db.exec("BEGIN");
|
|
2174
1926
|
try {
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
1927
|
+
const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
|
|
1928
|
+
if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
|
|
1929
|
+
const { slug, ledgerPath, intentEntry, commit } = pre;
|
|
1930
|
+
const publishAttempt = intentEntry.publish_attempt;
|
|
1931
|
+
const version = intentEntry.version;
|
|
1932
|
+
if (!Number.isInteger(publishAttempt) || publishAttempt < 1 ||
|
|
1933
|
+
version !== derivePublishVersion(commit, publishAttempt)) {
|
|
2179
1934
|
db.exec("ROLLBACK");
|
|
2180
|
-
return { recorded: false, reason: "
|
|
1935
|
+
return { recorded: false, reason: "intent entry carries no usable publish_attempt/version — refusing to record" };
|
|
1936
|
+
}
|
|
1937
|
+
const ts = now();
|
|
1938
|
+
const ledgerLine = {
|
|
1939
|
+
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
1940
|
+
commit, attempt: intentEntry.attempt || null, publish_attempt: publishAttempt, version,
|
|
1941
|
+
agent_id: null, applied_report: null,
|
|
1942
|
+
issuer: "tick-worker",
|
|
1943
|
+
diff_sha256: intentEntry.diff_sha256 || null,
|
|
1944
|
+
issued_at: editOutcome === "accepted" ? ts : null,
|
|
1945
|
+
};
|
|
1946
|
+
let note;
|
|
1947
|
+
if (editOutcome === "refused") {
|
|
1948
|
+
const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
|
|
1949
|
+
ledgerLine.outcome = "rejected";
|
|
1950
|
+
ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}`;
|
|
1951
|
+
// Terminal: parked for human attention. "publish: publish-refused"
|
|
1952
|
+
// is registered in lib/publish-note-vocabulary.js (the closed
|
|
1953
|
+
// registry — the writer-side guard pattern from D7).
|
|
1954
|
+
note = `publish: publish-refused ${commit} attempt=${publishAttempt} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
|
|
1955
|
+
} else {
|
|
1956
|
+
ledgerLine.outcome = "submitted";
|
|
1957
|
+
ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt";
|
|
1958
|
+
note = `publish: edit-issued ${commit} attempt=${publishAttempt} version=${version} — issued directly by the session-carrying tick worker (one-party publish). The acknowledgement scan owns the task from here: exact version string, 30-minute window.`;
|
|
2181
1959
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
1960
|
+
try {
|
|
1961
|
+
appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
|
|
1962
|
+
} catch (e) {
|
|
2184
1963
|
db.exec("ROLLBACK");
|
|
2185
|
-
return { recorded: false, reason:
|
|
1964
|
+
return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
|
|
2186
1965
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
1966
|
+
writeGuardedPublishNote(db, taskId, note, ts);
|
|
1967
|
+
db.exec("COMMIT");
|
|
1968
|
+
return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, publish_attempt: publishAttempt, note };
|
|
1969
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1970
|
+
};
|
|
1971
|
+
commands["record-intent-unissuable"] = (db, args, ctx) => {
|
|
1972
|
+
const taskId = args.task_id;
|
|
1973
|
+
const claimExpiry = args.claim_expiry;
|
|
1974
|
+
const reason = String(args.reason || "").split("\n")[0].slice(0, 200);
|
|
1975
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
1976
|
+
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
1977
|
+
if (!reason) throw usageError("reason is required.");
|
|
2189
1978
|
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
1979
|
+
db.exec("BEGIN");
|
|
1980
|
+
try {
|
|
1981
|
+
const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
|
|
1982
|
+
if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
|
|
1983
|
+
const { slug, ledgerPath, intentEntry, commit } = pre;
|
|
1984
|
+
const ts = now();
|
|
1985
|
+
const attempt = intentEntry.attempt || null;
|
|
1986
|
+
const ledgerLine = {
|
|
1987
|
+
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
1988
|
+
commit, attempt, agent_id: null, applied_report: null,
|
|
1989
|
+
issuer: "tick-worker", outcome: "unissuable", issued_at: null,
|
|
1990
|
+
diff_sha256: intentEntry.diff_sha256 || null,
|
|
1991
|
+
manifest_before: intentEntry.manifest_before || null,
|
|
1992
|
+
detail: `intent diff unissuable: ${reason} — the staged diff cannot be (re)generated byte-identically; parked for human attention`,
|
|
2198
1993
|
};
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
return { recorded: true, decision: verdict, task_id: taskId, note: "deferred — claim left to expire" };
|
|
1994
|
+
try {
|
|
1995
|
+
appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
db.exec("ROLLBACK");
|
|
1998
|
+
return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
|
|
2205
1999
|
}
|
|
2000
|
+
// Terminal: "publish: publish-unissuable" is registered in
|
|
2001
|
+
// lib/publish-note-vocabulary.js (the closed registry — the writer-side
|
|
2002
|
+
// guard pattern from D7).
|
|
2003
|
+
const note = `publish: publish-unissuable ${commit} — the intent's staged diff cannot be (re)generated byte-identically (${reason}). Never re-claim, never loop; parked for human attention.`;
|
|
2004
|
+
writeGuardedPublishNote(db, taskId, note, ts);
|
|
2005
|
+
db.exec("COMMIT");
|
|
2006
|
+
return { recorded: true, task_id: taskId, commit, note };
|
|
2007
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2008
|
+
};
|
|
2009
|
+
// Acknowledgement scan (0.14.6 §1.3): evaluates acknowledgement evidence
|
|
2010
|
+
// for issued edits. Eric's contract: exact version acknowledgement is the
|
|
2011
|
+
// SOLE positive completion criterion — "If we hear that the artifact
|
|
2012
|
+
// acknowledges our version, that's it. We don't verify against content."
|
|
2013
|
+
//
|
|
2014
|
+
// Per parked task whose latest publish: note is `publish: edit-issued`:
|
|
2015
|
+
// - the artifact's on-disk version receipt
|
|
2016
|
+
// (<spaces>/ts-spaces/<slug>/.crew-publish-version-<task_id>) equals ANY issued
|
|
2017
|
+
// version for this task+commit (a late earlier attempt counts), OR a
|
|
2018
|
+
// version-echoing builder report (outcome acknowledged) names one
|
|
2019
|
+
// → acknowledged: provenance stamped (certifying the request was
|
|
2020
|
+
// acknowledged, not byte equality), terminal
|
|
2021
|
+
// `publish: version-acknowledged`, task re-queued.
|
|
2022
|
+
// - else a builder report (outcome refused) names an issued version
|
|
2023
|
+
// → refused: terminal `publish: publish-refused`.
|
|
2024
|
+
// - else the 30-minute window from the LATEST issuance is still open
|
|
2025
|
+
// → skipped (window-open).
|
|
2026
|
+
// - else the 2-hour total budget from the FIRST issuance is not exhausted
|
|
2027
|
+
// → reissued: a fresh intent is staged with a fresh version,
|
|
2028
|
+
// immediately claimable (no backoff); the NEXT tick's intent scan
|
|
2029
|
+
// claims it. This tick issues nothing.
|
|
2030
|
+
// - else → version-timeout: terminal `publish: version-timeout`.
|
|
2031
|
+
//
|
|
2032
|
+
// One atomic transaction per scan. The command is fully code-local — no
|
|
2033
|
+
// claim notes, no tick mediation. `spaces_root` arg overrides the ts-spaces
|
|
2034
|
+
// root (tests); production reads ~/workspace/ts-spaces.
|
|
2035
|
+
//
|
|
2036
|
+
// Returns { acknowledged, refused, reissued, timeouts, skipped }.
|
|
2037
|
+
commands["scan-ack-pending"] = (db, args, ctx) => {
|
|
2038
|
+
const crewHome = ctx.crewHome;
|
|
2039
|
+
const nowMs = Date.now();
|
|
2040
|
+
// (2026-09-20, Eric's call) 30-minute acknowledgement window per attempt,
|
|
2041
|
+
// 2-hour TOTAL budget from the FIRST issuance of the commit — then terminal
|
|
2042
|
+
// version-timeout. No backoff: a reissue staged at window expiry is
|
|
2043
|
+
// immediately claimable; the window expiry is the only spacing. The window
|
|
2044
|
+
// anchors at the LATEST issuance (each attempt gets its own window); the
|
|
2045
|
+
// budget anchors at the FIRST (the whole publish effort gets one budget,
|
|
2046
|
+
// however many attempts fit). The 30-minute window is evidence-set: room
|
|
2047
|
+
// #25's one healthy ack landed ~15-17 minutes after issuance — the window
|
|
2048
|
+
// is ~2x that plus margin. The ledger records issued_at and first-seen ack
|
|
2049
|
+
// for every attempt, so the real distribution is measurable and the window
|
|
2050
|
+
// is tunable from data, not from a placeholder.
|
|
2051
|
+
const ACK_WINDOW_MS = 30 * 60 * 1000;
|
|
2052
|
+
const TOTAL_BUDGET_MS = 2 * 60 * 60 * 1000;
|
|
2053
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2054
|
+
const spacesRoot = (args && args.spaces_root) || join(homedir(), "workspace", "ts-spaces");
|
|
2055
|
+
// The release running this scan must provably be the active release
|
|
2056
|
+
// before it stamps acknowledgement claims — fail the whole scan closed
|
|
2057
|
+
// on a stale cron body or mid-deploy swap.
|
|
2058
|
+
const releaseName = resolveActiveRelease(crewHome);
|
|
2059
|
+
const computeDiffScript = new URL("./compute-publish-diff.js", import.meta.url).pathname;
|
|
2060
|
+
|
|
2061
|
+
const acknowledged = [];
|
|
2062
|
+
const refused = [];
|
|
2063
|
+
const reissued = [];
|
|
2064
|
+
const timeouts = [];
|
|
2065
|
+
const skipped = [];
|
|
2066
|
+
const skip = (taskId, reason, detail) =>
|
|
2067
|
+
skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
|
|
2206
2068
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2069
|
+
const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
|
|
2070
|
+
|
|
2071
|
+
db.exec("BEGIN");
|
|
2072
|
+
try {
|
|
2073
|
+
for (const row of parked) {
|
|
2074
|
+
const taskId = row.task_id;
|
|
2075
|
+
const latest = latestPublishNote(db, taskId);
|
|
2076
|
+
if (!latest || !latest.message.includes("publish: edit-issued")) { skip(taskId, "not-issued"); continue; }
|
|
2077
|
+
const m = latest.message.match(/publish:\s*edit-issued\s+([0-9a-f]{40})\s+attempt=(\d+)/);
|
|
2078
|
+
if (!m) {
|
|
2079
|
+
skip(taskId, "edit-issued-unparseable",
|
|
2080
|
+
"the edit-issued note carries no commit hash + attempt=N — stays parked for human attention");
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
const commit = m[1];
|
|
2084
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2085
|
+
const project = task && db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
2086
|
+
const slug = project && project.deploy_slug;
|
|
2087
|
+
if (!slug) { skip(taskId, "no-deploy-slug"); continue; }
|
|
2088
|
+
const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
|
|
2089
|
+
if (!ledger.ok) { skip(taskId, "ledger-unreadable", ledger.reason); continue; }
|
|
2090
|
+
// The issuance record: issuer-stamped submitted entries with
|
|
2091
|
+
// derivation-verified versions. A version that fails the derivation is
|
|
2092
|
+
// not this machine's — the whole attempt is unusable, fail closed.
|
|
2093
|
+
const submittedAll = findSubmittedEntries(ledger.entries, commit);
|
|
2094
|
+
if (submittedAll.length === 0) {
|
|
2095
|
+
skip(taskId, "no-issuance-record",
|
|
2096
|
+
"no issuer-stamped submitted ledger entry for this commit — failing closed; stays parked for human attention");
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
// The ack version is derived deterministically from (task, commit,
|
|
2100
|
+
// attempt) — §1.1. An entry whose version fails the derivation is a
|
|
2101
|
+
// phantom claim: it never binds (D2). A corrupt entry poisons only
|
|
2102
|
+
// itself — it is quarantined while valid entries still drive the
|
|
2103
|
+
// windows and the budget, so one bad ledger line can never park the
|
|
2104
|
+
// whole record before the budget is evaluated. Only
|
|
2105
|
+
// derivation-verified entries anchor the clocks below.
|
|
2106
|
+
const attempts = [];
|
|
2107
|
+
const validEntries = [];
|
|
2108
|
+
for (const e of submittedAll) {
|
|
2109
|
+
const pa = verifiedSubmittedAttempt(taskId, e);
|
|
2110
|
+
if (pa === null) continue;
|
|
2111
|
+
if (!attempts.includes(pa)) attempts.push(pa);
|
|
2112
|
+
validEntries.push(e);
|
|
2113
|
+
}
|
|
2114
|
+
if (attempts.length === 0) {
|
|
2115
|
+
skip(taskId, "version-mismatch",
|
|
2116
|
+
"no submitted entry's version passes the deterministic derivation — the issuance record is unusable; stays parked for human attention");
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
attempts.sort((a, b) => a - b);
|
|
2120
|
+
const expectedVersion = (a) => derivePublishVersion(commit, a);
|
|
2121
|
+
|
|
2122
|
+
// --- acknowledgement evidence (§1.3): the version receipt on disk, or
|
|
2123
|
+
// a version-echoing builder report. ANY issued version for this
|
|
2124
|
+
// task+commit counts — including a late earlier attempt's. The
|
|
2125
|
+
// receipt is scoped per task (`.crew-publish-version-<task_id>`):
|
|
2126
|
+
// two tasks of the same project can sit in `edit-issued`
|
|
2127
|
+
// concurrently (per-project simultaneity defaults to 2), and a
|
|
2128
|
+
// shared per-slug receipt would let each issuance overwrite the
|
|
2129
|
+
// other's acknowledgement — receipt ping-pong with no false ack.
|
|
2130
|
+
let diskVersion = null;
|
|
2131
|
+
try {
|
|
2132
|
+
diskVersion = readFileSync(join(spacesRoot, slug, ".crew-publish-version-" + taskId), "utf8").trim() || null;
|
|
2133
|
+
if (diskVersion && !isPublishVersion(diskVersion)) diskVersion = null;
|
|
2134
|
+
} catch { diskVersion = null; }
|
|
2135
|
+
const reports = db.prepare(
|
|
2136
|
+
`SELECT version, outcome FROM builder_reports WHERE task_id = ?`
|
|
2137
|
+
).all(taskId);
|
|
2138
|
+
// (2026-09-20 REVIEW) Disk is the SOLE positive evidence. The
|
|
2139
|
+
// builder-report "acknowledged" path was circular: the tick worker
|
|
2140
|
+
// knows the version (it was in the scan entry), so report_text
|
|
2141
|
+
// containing the version proves version knowledge, not artifact/platform
|
|
2142
|
+
// origin. A worker can fabricate an acknowledged report and cause
|
|
2143
|
+
// false provenance stamping. Builder reports are retained for
|
|
2144
|
+
// explicit REFUSALS only (negative evidence).
|
|
2145
|
+
const ackedAttempt = attempts.find((a) => diskVersion === expectedVersion(a));
|
|
2146
|
+
if (ackedAttempt !== undefined) {
|
|
2147
|
+
recordVersionAck(db, ctx, {
|
|
2148
|
+
taskId, projectId: project.id, commit, version: expectedVersion(ackedAttempt), via: "disk",
|
|
2149
|
+
});
|
|
2150
|
+
acknowledged.push({ task_id: taskId, commit, attempt: ackedAttempt, via: "disk", crew_release: releaseName });
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
// Explicit refusal naming an issued version: terminal, no re-issue.
|
|
2154
|
+
const refusedAttempt = attempts.find((a) =>
|
|
2155
|
+
reports.some((r) => r.outcome === "refused" && r.version === expectedVersion(a)));
|
|
2156
|
+
if (refusedAttempt !== undefined) {
|
|
2157
|
+
writeGuardedPublishNote(db, taskId,
|
|
2158
|
+
`publish: publish-refused ${commit} attempt=${refusedAttempt} — the builder's report carried the ` +
|
|
2159
|
+
`attempt's exact version with an explicit refusal. No re-issue; parked for human attention.`,
|
|
2160
|
+
new Date(nowMs).toISOString());
|
|
2161
|
+
refused.push({ task_id: taskId, commit, attempt: refusedAttempt });
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
// The acknowledgement window runs from the LATEST issuance of this
|
|
2165
|
+
// commit — a 30-minute window PER ATTEMPT (0.14.6 §1.3, design §3, as
|
|
2166
|
+
// re-set 2026-09-20). Anchoring at the earliest issuance would give
|
|
2167
|
+
// attempts 2+ no window at all: they are always issued after attempt
|
|
2168
|
+
// 1's window expired, so the scan would re-issue or time out
|
|
2169
|
+
// immediately after they were issued. Late acknowledgements for any
|
|
2170
|
+
// previously issued version are still accepted above; only the
|
|
2171
|
+
// re-issue/timeout clock follows the latest attempt.
|
|
2172
|
+
const latestIssuance = validEntries[validEntries.length - 1];
|
|
2173
|
+
const issuedAtMs = Date.parse(latestIssuance.issued_at || latestIssuance.ts);
|
|
2174
|
+
if (issuedAtMs > 0 && nowMs < issuedAtMs + ACK_WINDOW_MS) { skip(taskId, "window-open"); continue; }
|
|
2175
|
+
// An unparseable latest issuance is an UNKNOWN window — never call
|
|
2176
|
+
// it "window-open". The budget check below still runs, so a corrupt
|
|
2177
|
+
// latest timestamp cannot defer the terminal timeout forever; only
|
|
2178
|
+
// the re-issue is withheld (re-issuing into an unknown window would
|
|
2179
|
+
// risk a duplicate issuance the budget cannot yet justify).
|
|
2180
|
+
// Window expired. Budget remains → re-issue with a FRESH version;
|
|
2181
|
+
// budget exhausted → terminal version-timeout. The budget is measured
|
|
2182
|
+
// from the FIRST issuance of this commit: the whole publish effort
|
|
2183
|
+
// gets one 2-hour budget, however many 30-minute attempts fit inside
|
|
2184
|
+
// it. A fixed attempt cap would false-timeout a slow-but-healthy
|
|
2185
|
+
// artifact; a pure window with no budget would retry forever.
|
|
2186
|
+
const firstIssuance = validEntries[0];
|
|
2187
|
+
const firstIssuedAtMs = Date.parse(firstIssuance.issued_at || firstIssuance.ts);
|
|
2188
|
+
if (!(firstIssuedAtMs > 0)) {
|
|
2189
|
+
skip(taskId, "first-issuance-unparseable",
|
|
2190
|
+
"the first submitted entry's issued_at is unparseable — failing closed; stays parked for human attention");
|
|
2191
|
+
continue;
|
|
2192
|
+
}
|
|
2193
|
+
const maxAttempt = Math.max(...attempts);
|
|
2194
|
+
if (nowMs - firstIssuedAtMs >= TOTAL_BUDGET_MS) {
|
|
2195
|
+
writeGuardedPublishNote(db, taskId,
|
|
2196
|
+
`publish: version-timeout ${commit} — the 2-hour acknowledgement budget is exhausted ` +
|
|
2197
|
+
`(${maxAttempt} attempts, no version acknowledgement; unobserved within budget — the edit may ` +
|
|
2198
|
+
`still have landed; the outcome is unknown). Parked for human attention.`,
|
|
2199
|
+
new Date(nowMs).toISOString());
|
|
2200
|
+
timeouts.push({ task_id: taskId, commit, attempts: maxAttempt });
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
if (!(issuedAtMs > 0)) {
|
|
2204
|
+
skip(taskId, "latest-issuance-unparseable",
|
|
2205
|
+
"the latest submitted entry's issued_at is unparseable — the acknowledgement window state is " +
|
|
2206
|
+
"unknown and the total budget is not exhausted; stays parked for human attention");
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
const newAttempt = maxAttempt + 1;
|
|
2210
|
+
// Idempotency: a previous scan may already have prepared this
|
|
2211
|
+
// attempt. The guard is keyed on the SAME anchor the intent scan
|
|
2212
|
+
// claims on — the `publish: publish-requested <commit> attempt=N`
|
|
2213
|
+
// note — not the ledger line. The ledger append and the note write
|
|
2214
|
+
// are not atomic: a crash between them (or a rolled-back COMMIT)
|
|
2215
|
+
// leaves an orphan ledger line with no note, and a line-keyed guard
|
|
2216
|
+
// would skip it as "already prepared" until the budget forced the
|
|
2217
|
+
// terminal timeout. Keyed on the note, a torn prepare self-heals:
|
|
2218
|
+
// the orphan line is content-identical (deterministic version) and the
|
|
2219
|
+
// intent scan claims the re-appended line exactly once. Do NOT
|
|
2220
|
+
// reverse the write order: a note-without-line tear would park
|
|
2221
|
+
// permanently with no budget-expiry resolution.
|
|
2222
|
+
const prepareNote = db.prepare(
|
|
2223
|
+
`SELECT id FROM events WHERE task_id = ? AND type = 'note'
|
|
2224
|
+
AND message LIKE ? LIMIT 1`
|
|
2225
|
+
).get(taskId, `publish: publish-requested ${commit} attempt=${newAttempt} %`);
|
|
2226
|
+
if (prepareNote) { skip(taskId, "reissue-already-prepared"); continue; }
|
|
2227
|
+
// The re-issue diff is recomputed against CURRENT stamped provenance
|
|
2228
|
+
// (or the empty tree): the ack scan stages the attempt the way the
|
|
2229
|
+
// workflow staged attempt 1. A query error is not "no provenance" —
|
|
2230
|
+
// fail closed.
|
|
2231
|
+
let base;
|
|
2232
|
+
try {
|
|
2233
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
2234
|
+
base = (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit))
|
|
2235
|
+
? prov.provenance_source_commit : EMPTY_TREE;
|
|
2236
|
+
} catch (e) {
|
|
2237
|
+
skip(taskId, "provenance-query-failed",
|
|
2238
|
+
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
const repoPath = project.repo_path;
|
|
2242
|
+
if (!repoPath) { skip(taskId, "no-repo-path"); continue; }
|
|
2243
|
+
const newVersion = derivePublishVersion(commit, newAttempt);
|
|
2244
|
+
const diffPath = join(crewHome, ".publish-diffs", `${taskId}-a${newAttempt}.diff`);
|
|
2245
|
+
let diffJson = null;
|
|
2246
|
+
try {
|
|
2247
|
+
const out = execFileSync("node", [computeDiffScript,
|
|
2248
|
+
"--repo-path", repoPath, "--base", base, "--commit", commit,
|
|
2249
|
+
"--task-id", taskId, "--attempt", String(newAttempt), "--out", diffPath],
|
|
2250
|
+
{ encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
|
|
2251
|
+
diffJson = JSON.parse(String(out).trim());
|
|
2252
|
+
} catch (e) {
|
|
2253
|
+
skip(taskId, "reissue-diff-failed", `diff recomputation failed: ${String((e && e.message) || e).slice(0, 200)}`);
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
if (!diffJson || diffJson.ok !== true || diffJson.version !== newVersion ||
|
|
2257
|
+
!/^[0-9a-f]{64}$/.test(diffJson.sha256 || "")) {
|
|
2258
|
+
skip(taskId, "reissue-diff-unusable",
|
|
2259
|
+
"the recomputed diff is unusable (version mismatch or bad checksum) — stays parked for human attention");
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
// No backoff: the reissue is immediately claimable by the next intent
|
|
2263
|
+
// scan. The window expiry is the only spacing — a slow artifact gets
|
|
2264
|
+
// its full window per attempt, and a dropped one is retried on the
|
|
2265
|
+
// next tick, not after an arbitrary extra wait.
|
|
2266
|
+
const ts = new Date(nowMs).toISOString();
|
|
2267
|
+
const intentLine = {
|
|
2268
|
+
ts, task_id: taskId, workflow: "ack-reissue", slug,
|
|
2269
|
+
commit, attempt: null, publish_attempt: newAttempt, version: newVersion,
|
|
2270
|
+
agent_id: null, applied_report: null,
|
|
2271
|
+
issuer: "tick-worker",
|
|
2272
|
+
diff_path: diffPath, diff_sha256: diffJson.sha256,
|
|
2273
|
+
files: Array.isArray(diffJson.files) ? diffJson.files : null,
|
|
2274
|
+
base, outcome: "publish-intent",
|
|
2275
|
+
detail: `ack scan re-issue: attempt ${maxAttempt}'s 30-minute window expired with no version acknowledgement; ` +
|
|
2276
|
+
`fresh version staged, immediately claimable (no backoff)`,
|
|
2277
|
+
};
|
|
2278
|
+
try {
|
|
2279
|
+
appendFileSync(ledger.ledgerPath, JSON.stringify(intentLine) + "\n");
|
|
2280
|
+
} catch (e) {
|
|
2281
|
+
skip(taskId, "reissue-ledger-failed", `cannot append publish ledger: ${e.message}`);
|
|
2282
|
+
continue;
|
|
2283
|
+
}
|
|
2284
|
+
writeGuardedPublishNote(db, taskId,
|
|
2285
|
+
`publish: publish-requested ${commit} attempt=${newAttempt} — ack scan re-issue attempt ${newAttempt}: ` +
|
|
2286
|
+
`no version acknowledgement within the 30-minute window; fresh version staged, immediately claimable ` +
|
|
2287
|
+
`(2-hour total budget from first issuance).`,
|
|
2288
|
+
ts);
|
|
2289
|
+
reissued.push({ task_id: taskId, commit, attempt: newAttempt });
|
|
2228
2290
|
}
|
|
2229
2291
|
db.exec("COMMIT");
|
|
2230
|
-
return { recorded: true, decision: verdict, task_id: taskId, note };
|
|
2231
2292
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2293
|
+
|
|
2294
|
+
return { acknowledged, refused, reissued, timeouts, skipped };
|
|
2232
2295
|
};
|
|
2233
2296
|
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
// than provably-dropped. This command routes that decision — code-owned, so
|
|
2238
|
-
// the tick never free-forms the state machine.
|
|
2297
|
+
// Record the artifact platform's report text for an issued edit (0.14.6
|
|
2298
|
+
// §1.4). The tick worker calls this after each artifact_edit call with the
|
|
2299
|
+
// result/refusal text verbatim.
|
|
2239
2300
|
//
|
|
2240
|
-
// Args: { task_id,
|
|
2241
|
-
//
|
|
2242
|
-
// CAS: the latest publish: note must still be "publish: dropped" — the merge
|
|
2243
|
-
// lock is the mutual exclusion; the CAS is the backstop against a tick that
|
|
2244
|
-
// died after another writer moved the state.
|
|
2301
|
+
// Args: { task_id, version, outcome: "acknowledged"|"refused",
|
|
2302
|
+
// report_text }.
|
|
2245
2303
|
//
|
|
2246
|
-
//
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
const
|
|
2304
|
+
// Mechanical guards (the command, not prose, decides):
|
|
2305
|
+
// - report_text must contain the EXACT version string, else rejected;
|
|
2306
|
+
// - the version must match an issued attempt for this task (derived, never
|
|
2307
|
+
// trusted from prose), else rejected.
|
|
2308
|
+
// A refused report naming an issued version terminal-parks the task at the
|
|
2309
|
+
// next ack scan (`publish: publish-refused`). (2026-09-20 REVIEW) The
|
|
2310
|
+
// "acknowledged" outcome is rejected: it was circular (the worker knows
|
|
2311
|
+
// the version, so the report proves nothing), and disk is the sole positive
|
|
2312
|
+
// evidence. The rows persist as evidence.
|
|
2313
|
+
commands["record-builder-report"] = (db, args, ctx) => {
|
|
2314
|
+
const taskId = args.task_id;
|
|
2315
|
+
const version = args.version;
|
|
2316
|
+
const outcome = args.outcome;
|
|
2317
|
+
const reportText = args.report_text;
|
|
2258
2318
|
if (!taskId) throw usageError("task_id is required.");
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2319
|
+
if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
|
|
2320
|
+
// (2026-09-20 REVIEW) "acknowledged" is rejected — the positive
|
|
2321
|
+
// builder-report path was circular. Disk is the sole positive evidence.
|
|
2322
|
+
if (outcome !== "refused") throw usageError(`unknown outcome: ${outcome} (only "refused" is accepted).`);
|
|
2323
|
+
if (typeof reportText !== "string" || !reportText.includes(version)) {
|
|
2324
|
+
throw usageError("report_text must contain the exact version string — the report is mechanically rejected otherwise.");
|
|
2262
2325
|
}
|
|
2263
|
-
const
|
|
2264
|
-
if (!
|
|
2265
|
-
|
|
2326
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2327
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2328
|
+
if (task.state !== "parked") {
|
|
2329
|
+
throw usageError(`task is not parked (state=${task.state}) — builder reports attach to parked publish tasks.`);
|
|
2266
2330
|
}
|
|
2267
|
-
|
|
2268
|
-
const
|
|
2269
|
-
if (!
|
|
2270
|
-
const
|
|
2271
|
-
if (!
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2331
|
+
const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2332
|
+
const slug = project && project.deploy_slug;
|
|
2333
|
+
if (!slug) throw usageError("project has no deploy_slug.");
|
|
2334
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
2335
|
+
if (!ledger.ok) throw usageError(ledger.reason);
|
|
2336
|
+
let matched = null;
|
|
2337
|
+
for (const e of ledger.entries) {
|
|
2338
|
+
if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
|
|
2339
|
+
if (verifiedSubmittedAttempt(taskId, e) === null) continue;
|
|
2340
|
+
if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
|
|
2341
|
+
}
|
|
2342
|
+
if (!matched) {
|
|
2343
|
+
throw usageError("version does not match any issued attempt for this task — the report is rejected.");
|
|
2276
2344
|
}
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
const
|
|
2300
|
-
const
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2345
|
+
db.prepare(
|
|
2346
|
+
`INSERT INTO builder_reports (id, task_id, version, outcome, report_text, created_at)
|
|
2347
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2348
|
+
).run(uuid(), taskId, version, outcome, reportText.slice(0, 20000), now());
|
|
2349
|
+
return {
|
|
2350
|
+
ok: true, task_id: taskId, version, outcome,
|
|
2351
|
+
commit: matched.commit, publish_attempt: matched.publish_attempt,
|
|
2352
|
+
};
|
|
2353
|
+
};
|
|
2354
|
+
|
|
2355
|
+
// Record a positive version acknowledgement found off the ack scan's path
|
|
2356
|
+
// (0.14.6 §1.3): the re-claiming tick read the dead attempt's version receipt
|
|
2357
|
+
// from the artifact's disk. The version must name an issued attempt for this
|
|
2358
|
+
// task (derived, never trusted) — ANY issued version for the task counts,
|
|
2359
|
+
// including a late earlier attempt's. (2026-09-20 REVIEW) The reclaim path
|
|
2360
|
+
// also accepts the exact disk version for the currently claimed intent when
|
|
2361
|
+
// no submitted entry exists (dead tick issued but died before recording).
|
|
2362
|
+
// Stamps provenance, writes the terminal `publish: version-acknowledged`
|
|
2363
|
+
// note, re-queues the task.
|
|
2364
|
+
//
|
|
2365
|
+
// Args: { task_id, version, via: "disk" }.
|
|
2366
|
+
commands["record-version-ack"] = (db, args, ctx) => {
|
|
2367
|
+
const taskId = args.task_id;
|
|
2368
|
+
const version = args.version;
|
|
2369
|
+
const via = args.via;
|
|
2370
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2371
|
+
if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
|
|
2372
|
+
if (via !== "disk") throw usageError(`unknown via: ${via} (only "disk" is accepted).`);
|
|
2373
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2374
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2375
|
+
if (task.state !== "parked") {
|
|
2376
|
+
throw usageError(`task is not parked (state=${task.state}) — version acks attach to parked publish tasks.`);
|
|
2377
|
+
}
|
|
2378
|
+
const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2379
|
+
const slug = project && project.deploy_slug;
|
|
2380
|
+
if (!slug) throw usageError("project has no deploy_slug.");
|
|
2381
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
2382
|
+
if (!ledger.ok) throw usageError(ledger.reason);
|
|
2383
|
+
let matched = null;
|
|
2384
|
+
for (const e of ledger.entries) {
|
|
2385
|
+
if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
|
|
2386
|
+
if (verifiedSubmittedAttempt(taskId, e) === null) continue;
|
|
2387
|
+
if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
|
|
2388
|
+
}
|
|
2389
|
+
// (2026-09-20 REVIEW) Reclaim path: a tick that died after issuing but
|
|
2390
|
+
// before recording has no submitted entry. The code-written active claim
|
|
2391
|
+
// (publish-intent-claimed note) + the exact on-disk derived version for
|
|
2392
|
+
// the claimed intent's commit+attempt proves the dead issuance occurred.
|
|
2393
|
+
// Arbitrary/non-current/non-derived versions are still rejected.
|
|
2394
|
+
if (!matched) {
|
|
2395
|
+
const claimed = latestPublishNote(db, taskId);
|
|
2396
|
+
const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2397
|
+
if (claimed && claimed.message.includes("publish: publish-intent-claimed") &&
|
|
2398
|
+
intentEntry && /^[0-9a-f]{40}$/.test(intentEntry.commit || "") &&
|
|
2399
|
+
Number.isInteger(intentEntry.publish_attempt) && intentEntry.publish_attempt >= 1 &&
|
|
2400
|
+
derivePublishVersion(intentEntry.commit, intentEntry.publish_attempt) === version &&
|
|
2401
|
+
via === "disk") {
|
|
2402
|
+
matched = {
|
|
2403
|
+
commit: intentEntry.commit,
|
|
2404
|
+
publish_attempt: intentEntry.publish_attempt,
|
|
2405
|
+
// No submitted entry — the reclaim synthesizes the ack from the
|
|
2406
|
+
// claimed intent. The issued_at is unknown; the ack is timeless.
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
if (!matched) {
|
|
2411
|
+
throw usageError("version does not match any issued attempt for this task — the ack is rejected.");
|
|
2412
|
+
}
|
|
2413
|
+
db.exec("BEGIN");
|
|
2304
2414
|
try {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
} catch (e) { /* best-effort observability */ }
|
|
2312
|
-
return { recorded: true, decision: d, routed: terminal };
|
|
2415
|
+
const res = recordVersionAck(db, ctx, {
|
|
2416
|
+
taskId, projectId: project.id, commit: matched.commit, version, via,
|
|
2417
|
+
});
|
|
2418
|
+
db.exec("COMMIT");
|
|
2419
|
+
return res;
|
|
2420
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2313
2421
|
};
|
|
2314
2422
|
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2423
|
+
// The closed publish-note writer's prose entry point (0.14.6 §1.5): the
|
|
2424
|
+
// ONLY way for tick prose to write a state-carrying `publish: <verb>` note.
|
|
2425
|
+
// The verb is asserted against the closed registry in
|
|
2426
|
+
// lib/publish-note-vocabulary.js — prose cannot mint new `publish: <verb>`
|
|
2427
|
+
// states; an unregistered verb is a usage error, never a note.
|
|
2428
|
+
//
|
|
2429
|
+
// Args: { task_id, note } — the full note text.
|
|
2430
|
+
commands["record-publish-note"] = (db, args) => {
|
|
2431
|
+
const taskId = args.task_id;
|
|
2432
|
+
const note = String(args.note || "");
|
|
2433
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2434
|
+
if (!note) throw usageError("note is required.");
|
|
2435
|
+
const task = db.prepare("SELECT id, state FROM tasks WHERE id = ?").get(taskId);
|
|
2436
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2437
|
+
if (task.state !== "parked") {
|
|
2438
|
+
throw usageError(`task is not parked (state=${task.state}) — publish notes attach to parked tasks.`);
|
|
2439
|
+
}
|
|
2440
|
+
let verb;
|
|
2441
|
+
try {
|
|
2442
|
+
verb = assertWritablePublishNote(note);
|
|
2443
|
+
} catch (e) {
|
|
2444
|
+
throw usageError(e.message);
|
|
2445
|
+
}
|
|
2446
|
+
writePublishNote(db, taskId, note);
|
|
2447
|
+
return { ok: true, task_id: taskId, verb };
|
|
2448
|
+
};
|
|
2319
2449
|
commands["record-phase"] = (db, args) => {
|
|
2320
2450
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
2321
2451
|
requireTask(db, args.task_id);
|
|
@@ -2404,7 +2534,7 @@ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType
|
|
|
2404
2534
|
// Symlink-only resolution: the stamp records the crew home's ACTIVE
|
|
2405
2535
|
// release — it must not depend on which copy of this file is running.
|
|
2406
2536
|
// The strict self-path cross-check lives in resolveActiveRelease, used by
|
|
2407
|
-
//
|
|
2537
|
+
// the ack/intent scans; requiring it here silently skipped the stamp
|
|
2408
2538
|
// for every setup path that invokes crew-api.js outside the release tree
|
|
2409
2539
|
// (room #15: create-project through the npm package's own lib/).
|
|
2410
2540
|
try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
|
|
@@ -2650,7 +2780,7 @@ commands["update-config"] = (db, args) => {
|
|
|
2650
2780
|
// never touches another's row.
|
|
2651
2781
|
function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
|
|
2652
2782
|
// task_id invariant (2026-09-19 REVIEW): task_id is set by every REAL
|
|
2653
|
-
// publish (
|
|
2783
|
+
// publish (the version ack scan and the workflow provenance-refresh paths)
|
|
2654
2784
|
// and is null for initial stamps (tryStampInitialProvenance) and for
|
|
2655
2785
|
// publish-base advances (lib/advance-publish-base.js). So
|
|
2656
2786
|
// provenance.task_id != null ⟺ a real publish has stamped this project.
|
|
@@ -2745,12 +2875,12 @@ commands["migrate"] = (db, args) => {
|
|
|
2745
2875
|
|
|
2746
2876
|
// import-json: one-time import from a dashboard exportCrewState JSON payload.
|
|
2747
2877
|
// Used for the live migration (the artifact's app.db is never opened directly).
|
|
2748
|
-
// Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...] } }
|
|
2878
|
+
// Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...], builder_reports: [...] } }
|
|
2749
2879
|
// Or pipe the JSON via stdin.
|
|
2750
2880
|
commands["import-json"] = (db, args) => {
|
|
2751
2881
|
const data = args.data;
|
|
2752
2882
|
if (!data || typeof data !== "object") throw usageError("data must be the exportCrewState JSON object.");
|
|
2753
|
-
const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events"];
|
|
2883
|
+
const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events", "builder_reports"];
|
|
2754
2884
|
const counts = {};
|
|
2755
2885
|
db.exec("BEGIN");
|
|
2756
2886
|
try {
|