muse-crew 0.14.5 → 0.14.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +106 -12
- package/docs/decisions/AGENTS.md +5 -0
- package/docs/decisions/publish-path.md +247 -9
- package/docs/decisions/qa-reproduce.md +30 -0
- package/docs/guide.md +41 -5
- package/docs/publish-unknown-recovery.md +28 -120
- package/docs/publish-verification.md +148 -501
- package/lib/AGENTS.md +9 -13
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +82 -5
- package/lib/crew-api.js +1134 -1027
- package/lib/merge-lock.sh +153 -41
- package/lib/publish-note-vocabulary.js +135 -42
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +69 -0
- package/lib/serve-artifact.js +46 -2
- package/lib/test-detached-integrate.sh +122 -0
- package/lib/test-merge-lock.sh +30 -1
- package/lib/worktree-lifecycle.sh +284 -34
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +55 -89
- package/seed/crons.json +12 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +489 -224
- package/workflows/chore.js +306 -226
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +494 -251
- package/workflows/upgrade.js +13 -1
- package/lib/build-readback-request.js +0 -140
- package/lib/check-intent-freshness.js +0 -101
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -417
- package/lib/verify-publish.js +0 -416
package/lib/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
|
|
@@ -337,6 +338,74 @@ function openDb(crewHome) {
|
|
|
337
338
|
}
|
|
338
339
|
// Legacy global-provenance backfill (watermarked, runs at most once).
|
|
339
340
|
backfillProjectProvenance(db);
|
|
341
|
+
// Migration: cascade-park columns on tasks (room #26 blocker 36,
|
|
342
|
+
// 2026-09-21). A parked dep strands its todo dependents with zero
|
|
343
|
+
// telemetry; parking a task now cascade-parks them with structured
|
|
344
|
+
// attribution on the parked state (park_reason='dep_parked' +
|
|
345
|
+
// park_dep_id). Same idempotent PRAGMA-check pattern; ADD COLUMN
|
|
346
|
+
// without a default leaves existing rows NULL ("ordinary park", never
|
|
347
|
+
// fabricated). The pairing invariant lives in crew-api.js — SQLite
|
|
348
|
+
// forbids cross-column CHECKs on ADD COLUMN (verified live: "error in
|
|
349
|
+
// table after add column: no such column").
|
|
350
|
+
const taskCols = db.prepare("PRAGMA table_info(tasks)").all();
|
|
351
|
+
if (!taskCols.some((c) => c.name === "park_reason")) {
|
|
352
|
+
try {
|
|
353
|
+
db.exec("ALTER TABLE tasks ADD COLUMN park_reason TEXT CHECK (park_reason IS NULL OR park_reason = 'dep_parked')");
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
356
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!taskCols.some((c) => c.name === "park_dep_id")) {
|
|
360
|
+
try {
|
|
361
|
+
db.exec("ALTER TABLE tasks ADD COLUMN park_dep_id TEXT");
|
|
362
|
+
} catch (e) {
|
|
363
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
364
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Migration: structured verdict records (room #26 blocker 33,
|
|
368
|
+
// 2026-09-21). Review verdict grounds were destroyed by the 2000-char
|
|
369
|
+
// session-note truncation — a park on a verified-correct implementation
|
|
370
|
+
// was unauditable. record-phase now optionally carries the full grounds
|
|
371
|
+
// and writes them here, uncapped, in the same transaction as the session
|
|
372
|
+
// note. CREATE TABLE IF NOT EXISTS is natively idempotent (no PRAGMA
|
|
373
|
+
// dance needed, race-safe). One verdict per recording session
|
|
374
|
+
// (UNIQUE(session_id)): a re-recorded phase for the same session is the
|
|
375
|
+
// same evidence, so the first write wins and rows are never revised; a
|
|
376
|
+
// new Review execution claims a new session and gets its own row. New
|
|
377
|
+
// table: no backfill — pre-migration verdicts are unrecoverable, which
|
|
378
|
+
// is the point of this fix.
|
|
379
|
+
db.exec(`CREATE TABLE IF NOT EXISTS verdicts (
|
|
380
|
+
id TEXT PRIMARY KEY,
|
|
381
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
382
|
+
step TEXT NOT NULL,
|
|
383
|
+
attempt INTEGER NOT NULL CHECK (attempt >= 0),
|
|
384
|
+
reviewer TEXT NOT NULL,
|
|
385
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('PASS','FAIL','INDETERMINATE')),
|
|
386
|
+
grounds TEXT NOT NULL,
|
|
387
|
+
summary TEXT NOT NULL,
|
|
388
|
+
session_id TEXT NOT NULL,
|
|
389
|
+
created_at TEXT NOT NULL,
|
|
390
|
+
UNIQUE(session_id)
|
|
391
|
+
)`);
|
|
392
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_verdicts_task ON verdicts(task_id)`);
|
|
393
|
+
// Migration: review_basis on verdicts (room #26 blocker 34 redesign,
|
|
394
|
+
// 2026-09-21). The workflows were already sending review_basis in the
|
|
395
|
+
// verdict payload and the tests pinned "verdict row carries
|
|
396
|
+
// review_basis", but validateVerdictPayload dropped it and the table had
|
|
397
|
+
// no column — the structured basis never landed. Same idempotent
|
|
398
|
+
// PRAGMA-check pattern as the cascade-park columns; NULL for legacy
|
|
399
|
+
// rows (unclassified), never fabricated.
|
|
400
|
+
const verdictCols = db.prepare("PRAGMA table_info(verdicts)").all();
|
|
401
|
+
if (!verdictCols.some((c) => c.name === "review_basis")) {
|
|
402
|
+
try {
|
|
403
|
+
db.exec("ALTER TABLE verdicts ADD COLUMN review_basis TEXT");
|
|
404
|
+
} catch (e) {
|
|
405
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
406
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
340
409
|
}
|
|
341
410
|
return db;
|
|
342
411
|
}
|
|
@@ -396,6 +465,9 @@ function mapTask(row, taskStates = new Map()) {
|
|
|
396
465
|
deps,
|
|
397
466
|
filed_by: row.filed_by,
|
|
398
467
|
retry_reset_at: row.retry_reset_at,
|
|
468
|
+
// Room #26 blocker 36: cascade-park attribution (NULL = ordinary park).
|
|
469
|
+
park_reason: row.park_reason ?? null,
|
|
470
|
+
park_dep_id: row.park_dep_id ?? null,
|
|
399
471
|
blocked: deps.some((id) => taskStates.get(id) !== "done"),
|
|
400
472
|
created_at: row.created_at,
|
|
401
473
|
updated_at: row.updated_at,
|
|
@@ -433,6 +505,135 @@ function mapEvent(row) {
|
|
|
433
505
|
};
|
|
434
506
|
}
|
|
435
507
|
|
|
508
|
+
// Room #26 blocker 33 (2026-09-21): structured, non-lossy verdict records.
|
|
509
|
+
// The session note is truncated at 2000 chars by workflow design; the
|
|
510
|
+
// verdict row carries the full grounds, uncapped.
|
|
511
|
+
function mapVerdict(row) {
|
|
512
|
+
if (!row) return null;
|
|
513
|
+
return {
|
|
514
|
+
id: row.id,
|
|
515
|
+
task_id: row.task_id,
|
|
516
|
+
step: row.step,
|
|
517
|
+
attempt: row.attempt,
|
|
518
|
+
reviewer: row.reviewer,
|
|
519
|
+
verdict: row.verdict,
|
|
520
|
+
grounds: row.grounds,
|
|
521
|
+
review_basis: row.review_basis ?? null,
|
|
522
|
+
summary: row.summary,
|
|
523
|
+
session_id: row.session_id,
|
|
524
|
+
created_at: row.created_at,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// RUN-CLOSEOUT ON PARK (room #26 blocker 37, 2026-09-21). Park never
|
|
529
|
+
// closed workflow_runs rows — every park entry point left the task's open
|
|
530
|
+
// rows at 'running' forever. Every park path closes the task's open rows to
|
|
531
|
+
// the given terminal status in the same transaction as the park.
|
|
532
|
+
function closeWorkflowRuns(db, taskId, terminalStatus) {
|
|
533
|
+
const r = db.prepare(
|
|
534
|
+
`UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
|
|
535
|
+
WHERE task_id = ? AND status = 'running'`
|
|
536
|
+
).run(terminalStatus, taskId);
|
|
537
|
+
return Number(r.changes);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// CASCADE-PARK (room #26 blocker 36, 2026-09-21). Park every todo task that
|
|
541
|
+
// depends on any id in parkedIds, with structured attribution on the
|
|
542
|
+
// existing 'parked' state (park_reason='dep_parked', park_dep_id=<the parked
|
|
543
|
+
// dep>) — never a new state, never silent todo, never auto-waived. Then
|
|
544
|
+
// repeat to a fixpoint: a newly parked dependent strands its own
|
|
545
|
+
// dependents. Only todo tasks cascade — an in_progress task's running
|
|
546
|
+
// workflow owns its verdict, so it stays in flight. Emits one 'blocked'
|
|
547
|
+
// event per cascade-parked task, naming the dependent and the parked dep.
|
|
548
|
+
// Returns the list of {id, dep_id} pairs parked.
|
|
549
|
+
function cascadeDepParks(db, parkedIds, timestamp) {
|
|
550
|
+
const cascaded = [];
|
|
551
|
+
const frontier = [...parkedIds];
|
|
552
|
+
const seen = new Set(parkedIds);
|
|
553
|
+
const parkStmt = db.prepare(
|
|
554
|
+
`UPDATE tasks SET state = 'parked', park_reason = 'dep_parked', park_dep_id = ?, updated_at = ?
|
|
555
|
+
WHERE id = ?`);
|
|
556
|
+
const settleStmt = db.prepare(
|
|
557
|
+
`UPDATE agent_sessions SET status = 'failed', ended_at = ?
|
|
558
|
+
WHERE task_id = ? AND status = 'running'`);
|
|
559
|
+
const eventStmt = db.prepare(
|
|
560
|
+
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
561
|
+
VALUES (?, 'blocked', ?, NULL, ?, ?)`);
|
|
562
|
+
while (frontier.length > 0) {
|
|
563
|
+
const parkedId = frontier.shift();
|
|
564
|
+
const todoRows = db.prepare("SELECT id, deps FROM tasks WHERE state = 'todo'").all();
|
|
565
|
+
for (const row of todoRows) {
|
|
566
|
+
if (seen.has(row.id)) continue;
|
|
567
|
+
if (!parseDeps(row.deps).includes(parkedId)) continue;
|
|
568
|
+
parkStmt.run(parkedId, timestamp, row.id);
|
|
569
|
+
settleStmt.run(timestamp, row.id);
|
|
570
|
+
// Room #26 blocker 37: a cascade-parked dependent may carry a stale
|
|
571
|
+
// 'running' workflow_runs row (crashed prior run, requeued to todo) —
|
|
572
|
+
// close it with the park, in the same transaction. Every park path
|
|
573
|
+
// reaches its dependents through this function, so this one site
|
|
574
|
+
// covers park-task, update-task, retry-platform-failure, and the
|
|
575
|
+
// tick reconciliation sweep.
|
|
576
|
+
closeWorkflowRuns(db, row.id, "parked");
|
|
577
|
+
eventStmt.run(uuid(), row.id,
|
|
578
|
+
`dep-parked: ${row.id} parked — dep ${parkedId} is parked; ` +
|
|
579
|
+
`re-release ${parkedId} or waive this task via update-task to resume`,
|
|
580
|
+
timestamp);
|
|
581
|
+
seen.add(row.id);
|
|
582
|
+
cascaded.push({ id: row.id, dep_id: parkedId });
|
|
583
|
+
frontier.push(row.id);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return cascaded;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// RELEASE (room #26 blocker 36, 2026-09-21). When a task leaves 'parked'
|
|
590
|
+
// for any non-parked state, its cascade-parked dependents' contingency is
|
|
591
|
+
// lifted: re-evaluate each. If another of the dependent's deps is still
|
|
592
|
+
// parked, re-attribute the park to that dep (stays parked — never
|
|
593
|
+
// auto-waived past a parked dep); otherwise return it to todo with the
|
|
594
|
+
// attribution cleared. Then repeat to a fixpoint: a dependent returned to
|
|
595
|
+
// todo lifts the contingency of ITS cascade-parked dependents — the release
|
|
596
|
+
// cascades exactly as far as the park did. Emits one 'blocked' event per
|
|
597
|
+
// touched dependent. Returns the list of {id, action, dep_id} outcomes.
|
|
598
|
+
function releaseDepParks(db, releasedId, timestamp) {
|
|
599
|
+
const outcomes = [];
|
|
600
|
+
const frontier = [releasedId];
|
|
601
|
+
const seen = new Set([releasedId]);
|
|
602
|
+
const depStmt = db.prepare(
|
|
603
|
+
`SELECT id, deps FROM tasks
|
|
604
|
+
WHERE state = 'parked' AND park_reason = 'dep_parked' AND park_dep_id = ?`);
|
|
605
|
+
const stateOf = db.prepare("SELECT state FROM tasks WHERE id = ?");
|
|
606
|
+
const eventStmt = db.prepare(
|
|
607
|
+
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
608
|
+
VALUES (?, 'blocked', ?, NULL, ?, ?)`);
|
|
609
|
+
while (frontier.length > 0) {
|
|
610
|
+
const relId = frontier.shift();
|
|
611
|
+
for (const row of depStmt.all(relId)) {
|
|
612
|
+
if (seen.has(row.id)) continue;
|
|
613
|
+
seen.add(row.id);
|
|
614
|
+
const otherParked = parseDeps(row.deps).find(
|
|
615
|
+
(d) => d !== relId && stateOf.get(d)?.state === "parked");
|
|
616
|
+
if (otherParked) {
|
|
617
|
+
db.prepare("UPDATE tasks SET park_dep_id = ?, updated_at = ? WHERE id = ?")
|
|
618
|
+
.run(otherParked, timestamp, row.id);
|
|
619
|
+
eventStmt.run(uuid(), row.id,
|
|
620
|
+
`dep-parked: ${row.id} still parked — attribution moved from ${relId} to ${otherParked} (still parked)`,
|
|
621
|
+
timestamp);
|
|
622
|
+
outcomes.push({ id: row.id, action: "reattributed", dep_id: otherParked });
|
|
623
|
+
} else {
|
|
624
|
+
db.prepare("UPDATE tasks SET state = 'todo', park_reason = NULL, park_dep_id = NULL, updated_at = ? WHERE id = ?")
|
|
625
|
+
.run(timestamp, row.id);
|
|
626
|
+
eventStmt.run(uuid(), row.id,
|
|
627
|
+
`dep-released: ${row.id} returned to todo — dep ${relId} is no longer parked`,
|
|
628
|
+
timestamp);
|
|
629
|
+
outcomes.push({ id: row.id, action: "returned_todo" });
|
|
630
|
+
frontier.push(row.id);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return outcomes;
|
|
635
|
+
}
|
|
636
|
+
|
|
436
637
|
// Retry counting, ported verbatim from the dashboard's retry.ts.
|
|
437
638
|
function countConsecutiveFailures(sessions, retryResetAt) {
|
|
438
639
|
const latest = sessions[0];
|
|
@@ -565,6 +766,23 @@ commands["get-dispatch-state"] = (db) => {
|
|
|
565
766
|
WHERE status = 'running' AND datetime(COALESCE(last_heartbeat, started_at)) < datetime('now', '-1 hour')`
|
|
566
767
|
).run();
|
|
567
768
|
|
|
769
|
+
// Reconcile stranded dependents (room #26 blocker 36, 2026-09-21): park-time
|
|
770
|
+
// cascades catch new parks, but a legacy or raced row can still sit todo
|
|
771
|
+
// with a parked dep — and then it would sit forever with zero telemetry.
|
|
772
|
+
// Every dispatcher tick parks any todo task whose dep is currently
|
|
773
|
+
// parked, so no task sits todo with a parked dep for more than one tick.
|
|
774
|
+
// Idempotent (only todo tasks transition, and they stop being todo),
|
|
775
|
+
// and event-free for tasks already attributed — cascadeDepParks only
|
|
776
|
+
// writes for newly parked tasks. Same write-on-read precedent as the
|
|
777
|
+
// zombie-session sweep above.
|
|
778
|
+
{
|
|
779
|
+
const parkedIds = db
|
|
780
|
+
.prepare("SELECT id FROM tasks WHERE state = 'parked'")
|
|
781
|
+
.all()
|
|
782
|
+
.map((r) => r.id);
|
|
783
|
+
if (parkedIds.length > 0) cascadeDepParks(db, parkedIds, now());
|
|
784
|
+
}
|
|
785
|
+
|
|
568
786
|
const taskRows = db.prepare("SELECT * FROM tasks").all();
|
|
569
787
|
const projectRows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
|
|
570
788
|
const configRows = db.prepare("SELECT key, value FROM config WHERE key <> 'simultaneity'").all();
|
|
@@ -769,6 +987,13 @@ commands["update-task"] = (db, args, ctx) => {
|
|
|
769
987
|
const timestamp = now();
|
|
770
988
|
const patch = {};
|
|
771
989
|
setRetryResetAtForTransition(patch, current.state, args.state, timestamp);
|
|
990
|
+
// Room #26 blocker 36: leaving 'parked' clears this task's own cascade
|
|
991
|
+
// attribution — the parked->todo waiver is an explicit operator decision
|
|
992
|
+
// (the task returns todo with blocked=true until its deps are done).
|
|
993
|
+
if (current.state === "parked" && args.state !== undefined && args.state !== "parked") {
|
|
994
|
+
patch.park_reason = null;
|
|
995
|
+
patch.park_dep_id = null;
|
|
996
|
+
}
|
|
772
997
|
if (args.title !== undefined) {
|
|
773
998
|
const t = args.title.trim();
|
|
774
999
|
if (t.length < 1 || t.length > 200) throw usageError("title must be 1-200 chars.");
|
|
@@ -801,6 +1026,21 @@ commands["update-task"] = (db, args, ctx) => {
|
|
|
801
1026
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
802
1027
|
VALUES (?, ?, ?, NULL, ?, ?)`
|
|
803
1028
|
).run(uuid(), type, args.id, `Moved \u201c${patch.title ?? current.title}\u201d to ${args.state.replace("_", " ")}.`, timestamp);
|
|
1029
|
+
// Room #26 blocker 36: the dep is no longer parked, so re-evaluate
|
|
1030
|
+
// cascade-parked dependents — their contingency is lifted.
|
|
1031
|
+
if (current.state === "parked" && args.state !== "parked") {
|
|
1032
|
+
releaseDepParks(db, args.id, timestamp);
|
|
1033
|
+
}
|
|
1034
|
+
// Room #26 blocker 36: an operator parking a dep strands its todo
|
|
1035
|
+
// dependents with zero telemetry — cascade-park them in the same
|
|
1036
|
+
// transaction, under the same attribution contract as park-task.
|
|
1037
|
+
if (args.state === "parked" && current.state !== "parked") {
|
|
1038
|
+
// Blocker 37: an operator parking an in-flight task must close its
|
|
1039
|
+
// open workflow_runs rows in the same transaction — park-task and
|
|
1040
|
+
// retry-platform-failure already do; this path was the gap.
|
|
1041
|
+
closeWorkflowRuns(db, args.id, "parked");
|
|
1042
|
+
cascadeDepParks(db, [args.id], timestamp);
|
|
1043
|
+
}
|
|
804
1044
|
}
|
|
805
1045
|
db.exec("COMMIT");
|
|
806
1046
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
@@ -970,6 +1210,14 @@ commands["list-reservations"] = (db) => {
|
|
|
970
1210
|
commands["record-run-start"] = (db, args) => {
|
|
971
1211
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
972
1212
|
if (!args.workflow) throw usageError("workflow is required.");
|
|
1213
|
+
// A terminal task (parked, done) can never gain a new run: a stale agent
|
|
1214
|
+
// retrying a start against a task that was parked/done since would
|
|
1215
|
+
// resurrect it in the run timeline (2026-09-21 R-2). todo/in_progress
|
|
1216
|
+
// starts stay allowed — telemetry may record before the self-claim.
|
|
1217
|
+
const taskState = db.prepare("SELECT state FROM tasks WHERE id = ?").get(args.task_id);
|
|
1218
|
+
if (taskState && (taskState.state === "parked" || taskState.state === "done")) {
|
|
1219
|
+
throw usageError("task " + args.task_id + " is " + taskState.state + "; cannot start a workflow run.");
|
|
1220
|
+
}
|
|
973
1221
|
// The workflow doesn't know its platform run_id; the telemetry run_id is
|
|
974
1222
|
// minted here (SQLite-side) so workflow JS never touches randomness.
|
|
975
1223
|
const runId = args.run_id || uuid();
|
|
@@ -1020,9 +1268,12 @@ commands["record-run-end"] = (db, args) => {
|
|
|
1020
1268
|
if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
|
|
1021
1269
|
throw usageError("status must be completed, failed, parked, or timed_out.");
|
|
1022
1270
|
}
|
|
1271
|
+
// Terminal runs are immutable: a late run-end can never rewrite a run
|
|
1272
|
+
// the park/fail paths already closed (2026-09-21 R-2). Only a run still
|
|
1273
|
+
// in 'running' may transition; unknown run_ids match nothing (as before).
|
|
1023
1274
|
db.prepare(
|
|
1024
1275
|
`UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
|
|
1025
|
-
WHERE run_id =
|
|
1276
|
+
WHERE run_id = ? AND status = 'running'`
|
|
1026
1277
|
).run(status, args.run_id);
|
|
1027
1278
|
return { ok: true, run_id: args.run_id, status };
|
|
1028
1279
|
};
|
|
@@ -1175,8 +1426,15 @@ commands["retry-platform-failure"] = (db, args) => {
|
|
|
1175
1426
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1176
1427
|
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
1177
1428
|
).run(uuid(), failure.task_id, "Parked: " + msg, ts);
|
|
1429
|
+
// Room #26 blocker 37: park never closed workflow_runs rows — an open
|
|
1430
|
+
// 'running' row would sit forever. Close them to 'parked' in the
|
|
1431
|
+
// same transaction.
|
|
1432
|
+
const closedRuns = closeWorkflowRuns(db, failure.task_id, "parked");
|
|
1433
|
+
// Room #26 blocker 36: the parked dep's todo dependents strand with
|
|
1434
|
+
// zero telemetry otherwise — cascade-park them in the same transaction.
|
|
1435
|
+
const cascadeParked = cascadeDepParks(db, [failure.task_id], ts);
|
|
1178
1436
|
db.exec("COMMIT");
|
|
1179
|
-
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes) };
|
|
1437
|
+
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes), closed_workflow_runs: closedRuns, cascade_parked: cascadeParked };
|
|
1180
1438
|
} catch (e) {
|
|
1181
1439
|
db.exec("ROLLBACK");
|
|
1182
1440
|
throw e;
|
|
@@ -1274,12 +1532,21 @@ commands["park-task"] = (db, args) => {
|
|
|
1274
1532
|
notes = notes || ' Parked: ' || ?
|
|
1275
1533
|
WHERE task_id = ? AND status = 'running'`
|
|
1276
1534
|
).run(timestamp, message.slice(0, 500), task.id);
|
|
1535
|
+
// Room #26 blocker 37: park never closed workflow_runs rows — an open
|
|
1536
|
+
// 'running' row would sit forever. Close them to 'parked' in the same
|
|
1537
|
+
// transaction (park is the designed terminal state for the run too).
|
|
1538
|
+
const closedRuns = closeWorkflowRuns(db, task.id, "parked");
|
|
1539
|
+
// Room #26 blocker 36: cascade-park todo dependents in the same
|
|
1540
|
+
// transaction — a parked dep strands them with zero telemetry otherwise.
|
|
1541
|
+
const cascadeParked = cascadeDepParks(db, [task.id], timestamp);
|
|
1277
1542
|
db.exec("COMMIT");
|
|
1278
1543
|
const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
|
|
1279
1544
|
return {
|
|
1280
1545
|
ok: true,
|
|
1281
1546
|
task: mapTask({ ...task, state: "parked", updated_at: timestamp }, taskStates),
|
|
1282
1547
|
settled_sessions: Number(settled.changes),
|
|
1548
|
+
closed_workflow_runs: closedRuns,
|
|
1549
|
+
cascade_parked: cascadeParked,
|
|
1283
1550
|
};
|
|
1284
1551
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1285
1552
|
};
|
|
@@ -1323,7 +1590,9 @@ commands["recover-task"] = (db, args, ctx) => {
|
|
|
1323
1590
|
const note = (args.updated_description ?? "").trim().slice(0, 5000);
|
|
1324
1591
|
db.exec("BEGIN");
|
|
1325
1592
|
try {
|
|
1326
|
-
|
|
1593
|
+
// Room #26 blocker 36: recovery out of 'parked' clears this task's own
|
|
1594
|
+
// cascade attribution and re-evaluates cascade-parked dependents.
|
|
1595
|
+
db.prepare("UPDATE tasks SET next_phase = ?, state = ?, park_reason = NULL, park_dep_id = NULL, updated_at = ? WHERE id = ?")
|
|
1327
1596
|
.run(targetPhase, newState, timestamp, task.id);
|
|
1328
1597
|
db.prepare(
|
|
1329
1598
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
@@ -1333,11 +1602,14 @@ commands["recover-task"] = (db, args, ctx) => {
|
|
|
1333
1602
|
(task.state !== newState ? ` (state ${task.state} -> ${newState})` : "") +
|
|
1334
1603
|
(note ? `. ${note}` : ""),
|
|
1335
1604
|
timestamp);
|
|
1605
|
+
if (task.state === "parked" && newState !== "parked") {
|
|
1606
|
+
releaseDepParks(db, task.id, timestamp);
|
|
1607
|
+
}
|
|
1336
1608
|
db.exec("COMMIT");
|
|
1337
1609
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1338
1610
|
const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
|
|
1339
1611
|
return {
|
|
1340
|
-
task: mapTask({ ...task, next_phase: targetPhase, state: newState, updated_at: timestamp }, taskStates),
|
|
1612
|
+
task: mapTask({ ...task, next_phase: targetPhase, state: newState, park_reason: null, park_dep_id: null, updated_at: timestamp }, taskStates),
|
|
1341
1613
|
valid_phases: stepNames,
|
|
1342
1614
|
};
|
|
1343
1615
|
};
|
|
@@ -1425,6 +1697,17 @@ commands["log-event"] = (db, args) => {
|
|
|
1425
1697
|
if (!valid.includes(args.type)) throw usageError(`bad event type: ${args.type}.`);
|
|
1426
1698
|
const message = (args.message ?? "").trim();
|
|
1427
1699
|
if (message.length < 1 || message.length > 1000) throw usageError("message is required (1-1000 chars).");
|
|
1700
|
+
// (2026-09-20 REVIEW) State-carrying `publish: <verb>` notes are a closed
|
|
1701
|
+
// set: they are read by the ack/intent scans from the events table, so a
|
|
1702
|
+
// prose log-event with type "note" would bypass the vocabulary guard.
|
|
1703
|
+
// Route them through `record-publish-note` (prose) or the guarded code
|
|
1704
|
+
// writer — never log-event.
|
|
1705
|
+
if (args.type === "note" && /publish:\s*[a-z0-9-]+/.test(message)) {
|
|
1706
|
+
throw usageError(
|
|
1707
|
+
"state-carrying publish notes are closed-vocabulary (lib/publish-note-vocabulary.js) — " +
|
|
1708
|
+
"write them via the `record-publish-note` command, not log-event."
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1428
1711
|
if (args.task_id) requireTask(db, args.task_id);
|
|
1429
1712
|
const event = {
|
|
1430
1713
|
id: uuid(), type: args.type, task_id: args.task_id ?? null,
|
|
@@ -1445,36 +1728,15 @@ commands["get-events"] = (db, args) => {
|
|
|
1445
1728
|
return { events: rows.map(mapEvent) };
|
|
1446
1729
|
};
|
|
1447
1730
|
|
|
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.
|
|
1459
|
-
//
|
|
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 }.
|
|
1731
|
+
// Active-release resolution helpers (2026-09-18, room #15).
|
|
1468
1732
|
//
|
|
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
1733
|
// resolveActiveReleaseName (symlink only — for writers) below, and
|
|
1477
1734
|
// resolveActiveRelease (symlink + self-path cross-check — for verifiers).
|
|
1735
|
+
// The old "Parent publish verification: scan + atomic claim + reconcile"
|
|
1736
|
+
// block (scan-verification-pending, publish: verification-requested /
|
|
1737
|
+
// verification-claimed / verified) was deleted with the 0.14.6
|
|
1738
|
+
// version-acknowledgement rewrite — these helpers now serve the ack scan's
|
|
1739
|
+
// provenance writers only.
|
|
1478
1740
|
function resolveActiveReleaseName(crewHome) {
|
|
1479
1741
|
let linkTarget;
|
|
1480
1742
|
try {
|
|
@@ -1486,240 +1748,45 @@ function resolveActiveReleaseName(crewHome) {
|
|
|
1486
1748
|
}
|
|
1487
1749
|
return basename(resolve(crewHome, linkTarget));
|
|
1488
1750
|
}
|
|
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
|
|
1751
|
+
// The strict resolver for provenance writers (scan-ack-pending and
|
|
1752
|
+
// record-version-ack): the symlink answer PLUS the self-path cross-check —
|
|
1753
|
+
// the running code must provably be the active release's own crew-api.js
|
|
1754
|
+
// before it stamps acknowledgement claims. Stale cron bodies and mid-deploy
|
|
1755
|
+
// swaps throw fail-closed here.
|
|
1493
1756
|
function resolveActiveRelease(crewHome) {
|
|
1494
1757
|
const activeRelease = resolveActiveReleaseName(crewHome);
|
|
1495
1758
|
const selfPath = new URL(import.meta.url).pathname;
|
|
1496
1759
|
const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
|
|
1497
1760
|
if (!selfMatch) {
|
|
1498
1761
|
throw usageError(
|
|
1499
|
-
`scan
|
|
1762
|
+
`ack/intent scan: running code is not inside a releases/<name>/lib path (${selfPath}). Refusing to stamp.`
|
|
1500
1763
|
);
|
|
1501
1764
|
}
|
|
1502
1765
|
if (selfMatch[1] !== activeRelease) {
|
|
1503
1766
|
throw usageError(
|
|
1504
|
-
`scan
|
|
1767
|
+
`ack/intent scan: running release ${selfMatch[1]} != active release ${activeRelease} (stale cron body or mid-deploy). Refusing to stamp.`
|
|
1505
1768
|
);
|
|
1506
1769
|
}
|
|
1507
1770
|
return activeRelease;
|
|
1508
1771
|
}
|
|
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
1772
|
// ---------------------------------------------------------------------------
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1637
|
-
// The publish-unknown recovery state machine (scan-publish-unknown, the
|
|
1638
|
-
// manual resolve-publish-unknown, record-unknown-classification) all start
|
|
1639
|
-
// from the same durable anchors: the parked task, its project's deploy_slug,
|
|
1640
|
-
// and the latest publish ledger entry for the task, which must be outcome
|
|
1641
|
-
// "unknown" with a usable commit hash. This helper reads those anchors once
|
|
1642
|
-
// so the three commands cannot drift apart.
|
|
1773
|
+
// Publish helpers (0.14.6 version-acknowledgement architecture).
|
|
1643
1774
|
//
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1775
|
+
// Publication is a handoff with strong suggestions: the workflow stages a
|
|
1776
|
+
// checksummed diff carrying a per-attempt version; the session-carrying tick
|
|
1777
|
+
// worker issues it via artifact_edit; acknowledgement is the EXACT version
|
|
1778
|
+
// string observed on the artifact's disk or in a version-echoing builder
|
|
1779
|
+
// report. Eric's contract: "If we hear that the artifact acknowledges our
|
|
1780
|
+
// version, that's it. We don't verify against content." Provenance certifies
|
|
1781
|
+
// that an issuance request was ACKNOWLEDGED — never that bytes matched.
|
|
1648
1782
|
// ---------------------------------------------------------------------------
|
|
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
1783
|
|
|
1713
|
-
// The
|
|
1714
|
-
//
|
|
1715
|
-
|
|
1716
|
-
// marker, so the prefilter must match it explicitly. LIKE is ASCII
|
|
1717
|
-
// case-insensitive in SQLite.
|
|
1718
|
-
const PUBLISH_NOTE_PREFILTER =
|
|
1719
|
-
`(message LIKE '%publish:%' OR message LIKE '%publish outcome unknown%')`;
|
|
1784
|
+
// The ack/intent scans read the note journal through this prefilter. LIKE
|
|
1785
|
+
// is ASCII case-insensitive in SQLite.
|
|
1786
|
+
const PUBLISH_NOTE_PREFILTER = `(message LIKE '%publish:%')`;
|
|
1720
1787
|
|
|
1721
|
-
// Latest publish: note for a task (the
|
|
1722
|
-
//
|
|
1788
|
+
// Latest publish: note for a task (the publish state machines' state), or
|
|
1789
|
+
// null when the task has no publish notes yet.
|
|
1723
1790
|
function latestPublishNote(db, taskId) {
|
|
1724
1791
|
return db.prepare(
|
|
1725
1792
|
`SELECT message, timestamp FROM events
|
|
@@ -1728,46 +1795,6 @@ function latestPublishNote(db, taskId) {
|
|
|
1728
1795
|
).get(taskId) || null;
|
|
1729
1796
|
}
|
|
1730
1797
|
|
|
1731
|
-
// The trigger anchor for an unknown attempt: the ISSUER-WRITTEN "submitted"
|
|
1732
|
-
// ledger entry for the same attempt (2026-09-20, one-party worker-owned
|
|
1733
|
-
// publish: only the session-carrying tick worker performs issuance, and it
|
|
1734
|
-
// stamps its writes issuer="tick-worker"). The workflow never issues and
|
|
1735
|
-
// never writes "submitted" — a workflow-written "submitted" (issuer absent,
|
|
1736
|
-
// the pre-one-party shape) is a phantom claim and must NOT bind. Within one
|
|
1737
|
-
// attempt the anchor is the OLDEST by ts (D1: the bound instant is
|
|
1738
|
-
// issued_at || ts, the same fallback verify-publish.js uses).
|
|
1739
|
-
// Returns the entry or null. A null means the trigger
|
|
1740
|
-
// instant is unknowable (or the trigger was never issued) — classification
|
|
1741
|
-
// cannot run; the task stays parked for human attention. Fail closed, never
|
|
1742
|
-
// guess the anchor.
|
|
1743
|
-
function findTriggerEntry(entries, commit, attempt) {
|
|
1744
|
-
// D1 (2026-09-19; entry_kind cut 2026-09-20; issuer anchor 2026-09-20):
|
|
1745
|
-
// the anchor is the OLDEST tick-worker "submitted" entry by ts and the
|
|
1746
|
-
// bound instant is its issued_at || ts — the same fallback
|
|
1747
|
-
// verify-publish.js uses. A "submitted" without issuer (the retired
|
|
1748
|
-
// workflow write) is not an issuance and never binds.
|
|
1749
|
-
const matches = [];
|
|
1750
|
-
for (let i = 0; i < entries.length; i++) {
|
|
1751
|
-
const e = entries[i];
|
|
1752
|
-
if (e.outcome === "submitted" && e.issuer === "tick-worker" &&
|
|
1753
|
-
e.commit === commit &&
|
|
1754
|
-
(e.attempt || null) === (attempt || null)) {
|
|
1755
|
-
matches.push(e);
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1759
|
-
return matches[0] || null;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
// The unknown ledger entry's detail says whether the trigger went out.
|
|
1763
|
-
// Unknown parks from preflight/toolcheck inconclusives never issued the
|
|
1764
|
-
// trigger ("the trigger was NOT issued") — there is no dropped edit to
|
|
1765
|
-
// recover, so the unknown-recovery machine must not classify or retry them.
|
|
1766
|
-
function triggerWasIssued(unknownEntry) {
|
|
1767
|
-
const detail = String((unknownEntry && unknownEntry.detail) || "");
|
|
1768
|
-
return !/trigger was NOT issued|no trigger issued/i.test(detail);
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
1798
|
function writePublishNote(db, taskId, message, ts) {
|
|
1772
1799
|
db.prepare(
|
|
1773
1800
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
@@ -1775,254 +1802,175 @@ function writePublishNote(db, taskId, message, ts) {
|
|
|
1775
1802
|
).run(uuid(), taskId, message, ts || now());
|
|
1776
1803
|
}
|
|
1777
1804
|
|
|
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
|
-
|
|
1811
|
-
return { resolved: false, reason: "publish window is empty or inverted" };
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
// Audit dirs are named <UTC-timestamp>-<id>; the timestamp is the build's.
|
|
1815
|
-
const auditsDir = join(homedir(), "workspace", "ts-spaces", slug, "audits");
|
|
1816
|
-
let evidenceDirs = [];
|
|
1805
|
+
// 0.14.6 §1.1 — the per-attempt publish version: "<commit>:<attempt>".
|
|
1806
|
+
// A plain version number, not a hash (2026-09-20: the sha256 "nonce"
|
|
1807
|
+
// defended against a forger that doesn't exist — the builder's failure mode
|
|
1808
|
+
// is omission, never forging our receipts — while the artifact's real failure
|
|
1809
|
+
// mode, an unfaithful carrier, defeats a hash and a counter identically. We
|
|
1810
|
+
// own the code, so we count our own attempts; we don't own the artifact, so
|
|
1811
|
+
// verification stays observation of its state, never its attestation).
|
|
1812
|
+
// Deterministic: no wall clock, no randomness. Byte-identical to
|
|
1813
|
+
// lib/compute-publish-diff.js's derivation (which synthesizes the
|
|
1814
|
+
// `.crew-publish-version-<task_id>` receipt into the staged diff), so the
|
|
1815
|
+
// workflow's staging, the tick's regeneration, and these scans all agree on
|
|
1816
|
+
// the string. The commit binds the version to the exact code (a stale receipt
|
|
1817
|
+
// from an earlier commit can never equal a fresh version); the attempt
|
|
1818
|
+
// counts re-issues of that commit. `attempt` is the 1-based publish attempt;
|
|
1819
|
+
// String(attempt) matches the argv string the diff script concatenates.
|
|
1820
|
+
function derivePublishVersion(commit, attempt) {
|
|
1821
|
+
return commit + ":" + String(attempt);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// A well-formed publish version: "<40-hex commit>:<positive-integer attempt>".
|
|
1825
|
+
function isPublishVersion(s) {
|
|
1826
|
+
return typeof s === "string" && /^[0-9a-f]{40}:[1-9][0-9]*$/.test(s);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// 0.14.6 §1.5 — the closed publish-note writer. Every state-carrying
|
|
1830
|
+
// publish-note write by deterministic code in this file goes through
|
|
1831
|
+
// writeGuardedPublishNote, which asserts the verb against the code-writable
|
|
1832
|
+
// registry (transitional + current terminals) in
|
|
1833
|
+
// lib/publish-note-vocabulary.js before writing. Tick prose mints notes
|
|
1834
|
+
// only through the `record-publish-note` command, which asserts the
|
|
1835
|
+
// prose-writable registry (transitional only — prose can never mint a
|
|
1836
|
+
// terminal state). A registry violation is a caller bug (usage).
|
|
1837
|
+
function writeGuardedPublishNote(db, taskId, message, ts) {
|
|
1817
1838
|
try {
|
|
1818
|
-
|
|
1819
|
-
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z-/.exec(name);
|
|
1820
|
-
if (!m) return false;
|
|
1821
|
-
const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
|
|
1822
|
-
return t > winStart && t <= winEnd;
|
|
1823
|
-
}).sort();
|
|
1839
|
+
assertCodeWritablePublishNote(message);
|
|
1824
1840
|
} catch (e) {
|
|
1825
|
-
|
|
1841
|
+
throw usageError(e.message);
|
|
1826
1842
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
}
|
|
1830
|
-
// (N3) Ambiguity guard: multiple builds in the recovery window have the
|
|
1831
|
-
// same attribution ambiguity as the workflow's immediate verdict — no
|
|
1832
|
-
// single dir can be attributed to this attempt, so resolution stays
|
|
1833
|
-
// unknown. Only when exactly one dir remains is evidenceDirs[0] safe.
|
|
1834
|
-
if (evidenceDirs.length > 1) {
|
|
1835
|
-
return { resolved: false, reason: `audit-dir ambiguity: ${evidenceDirs.length} audit dirs inside the publish window — still unknown` };
|
|
1836
|
-
}
|
|
1837
|
-
const evidenceDir = evidenceDirs[0];
|
|
1843
|
+
writePublishNote(db, taskId, message, ts);
|
|
1844
|
+
}
|
|
1838
1845
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1846
|
+
// The publish ledger is a per-slug JSONL file under the crew home
|
|
1847
|
+
// ($CREW_HOME/.publish-ledger/<slug>.jsonl): best-effort observability plus
|
|
1848
|
+
// the durable anchors the scans read (intent entries, submitted entries).
|
|
1849
|
+
function readTaskLedgerEntries(crewHome, slug, taskId) {
|
|
1850
|
+
const ledgerPath = join(crewHome, ".publish-ledger", slug + ".jsonl");
|
|
1841
1851
|
try {
|
|
1842
|
-
|
|
1843
|
-
|
|
1852
|
+
return {
|
|
1853
|
+
ok: true, ledgerPath,
|
|
1854
|
+
entries: readFileSync(ledgerPath, "utf8").split("\n")
|
|
1855
|
+
.filter((l) => l.trim().length > 0)
|
|
1856
|
+
.map((l) => JSON.parse(l))
|
|
1857
|
+
.filter((e) => e.task_id === taskId),
|
|
1858
|
+
};
|
|
1859
|
+
} catch (e) {
|
|
1860
|
+
return { ok: false, ledgerPath, reason: `cannot read publish ledger: ${e.message}` };
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1844
1863
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
).run(uuid(), taskId,
|
|
1855
|
-
`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.`,
|
|
1856
|
-
ts);
|
|
1857
|
-
// Mirror the workflow's verification-requested park note so
|
|
1858
|
-
// scan-verification-pending claims it on the next tick.
|
|
1859
|
-
db.prepare(
|
|
1860
|
-
"INSERT INTO events (id, type, task_id, identity, message, timestamp) VALUES (?, 'note', ?, NULL, ?, ?)"
|
|
1861
|
-
).run(uuid(), taskId,
|
|
1862
|
-
`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.`,
|
|
1863
|
-
tsLater);
|
|
1864
|
-
db.prepare("UPDATE tasks SET state = 'parked', updated_at = ? WHERE id = ?").run(tsLater, taskId);
|
|
1865
|
-
db.exec("COMMIT");
|
|
1866
|
-
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1864
|
+
// Latest publish-intent ledger entry for (task, commit, publish_attempt):
|
|
1865
|
+
// the issuance anchor. The workflow writes attempt 1 (issuer "workflow");
|
|
1866
|
+
// the ack scan's re-issue writes attempts 2+ (issuer "tick-worker").
|
|
1867
|
+
function findIntentEntry(entries, commit, publishAttempt) {
|
|
1868
|
+
const matches = entries.filter((e) => e.outcome === "publish-intent" &&
|
|
1869
|
+
e.commit === commit && (e.publish_attempt || null) === (publishAttempt || null));
|
|
1870
|
+
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1871
|
+
return matches[matches.length - 1] || null;
|
|
1872
|
+
}
|
|
1867
1873
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
agent_id: null,
|
|
1879
|
-
applied_report: null,
|
|
1880
|
-
outcome: "unknown-resolved",
|
|
1881
|
-
detail: `durable build evidence ${evidenceDir} (audit report ok=${auditOk}) inside publish window; routed to parent verification without re-trigger`,
|
|
1882
|
-
}) + "\n");
|
|
1883
|
-
} catch (e) { /* best-effort observability */ }
|
|
1884
|
-
|
|
1885
|
-
return { resolved: true, task_id: taskId, commit: latest.commit, evidence_dir: evidenceDir, audit_report_ok: auditOk };
|
|
1886
|
-
};
|
|
1874
|
+
// Issuer-written "submitted" entries for (task, commit): the issuance
|
|
1875
|
+
// record. Only the session-carrying tick worker performs issuance, and it
|
|
1876
|
+
// stamps its writes issuer="tick-worker" — anything else is a phantom claim
|
|
1877
|
+
// and never binds. Oldest first (D1: the bound instant is issued_at || ts).
|
|
1878
|
+
function findSubmittedEntries(entries, commit) {
|
|
1879
|
+
const matches = entries.filter((e) => e.outcome === "submitted" &&
|
|
1880
|
+
e.issuer === "tick-worker" && e.commit === commit);
|
|
1881
|
+
matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
1882
|
+
return matches;
|
|
1883
|
+
}
|
|
1887
1884
|
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
1890
|
-
// the
|
|
1891
|
-
// the
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1885
|
+
// 0.14.6 §1.3 — record a positive version acknowledgement: stamp provenance
|
|
1886
|
+
// (certifying the issuance REQUEST was acknowledged — never byte equality),
|
|
1887
|
+
// write the terminal `publish: version-acknowledged` note, and re-queue the
|
|
1888
|
+
// task. Shared by scan-ack-pending and the record-version-ack command (the
|
|
1889
|
+
// re-claim path). The strict release cross-check refuses to stamp from a
|
|
1890
|
+
// stale cron body or a mid-deploy swap — fail closed.
|
|
1891
|
+
function recordVersionAck(db, ctx, { taskId, projectId, commit, version, via }) {
|
|
1892
|
+
const releaseName = resolveActiveRelease(ctx.crewHome);
|
|
1893
|
+
const ts = now();
|
|
1894
|
+
stampProvenance(db, {
|
|
1895
|
+
project_id: projectId, source_commit: commit, crew_release: releaseName,
|
|
1896
|
+
published_at: ts, task_id: taskId,
|
|
1897
|
+
});
|
|
1898
|
+
writeGuardedPublishNote(db, taskId,
|
|
1899
|
+
`publish: version-acknowledged ${commit} via=${via} — the artifact acknowledged this attempt's exact version ` +
|
|
1900
|
+
`(${via === "disk" ? "on-disk receipt .crew-publish-version" : "version-echoing builder report"}); ` +
|
|
1901
|
+
`provenance stamped for crew release ${releaseName} (certifies the issuance request was acknowledged, not byte equality). Task re-queued.`,
|
|
1902
|
+
ts);
|
|
1903
|
+
db.prepare("UPDATE tasks SET state = 'in_progress', updated_at = ? WHERE id = ?").run(ts, taskId);
|
|
1904
|
+
return { ok: true, task_id: taskId, commit, version, via, crew_release: releaseName };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// The version on a submitted entry must be the deterministic one for its
|
|
1908
|
+
// (task, commit, publish_attempt) — the ledger is advisory; the derivation
|
|
1909
|
+
// is the contract. Returns the entry's publish_attempt or null.
|
|
1910
|
+
function verifiedSubmittedAttempt(taskId, entry) {
|
|
1911
|
+
const pa = entry.publish_attempt;
|
|
1912
|
+
if (!Number.isInteger(pa) || pa < 1) return null;
|
|
1913
|
+
if (!/^[0-9a-f]{40}$/.test(entry.commit || "")) return null;
|
|
1914
|
+
if (!isPublishVersion(entry.version)) return null;
|
|
1915
|
+
if (entry.version !== derivePublishVersion(entry.commit, pa)) return null;
|
|
1916
|
+
return pa;
|
|
1917
|
+
}
|
|
1918
|
+
// Publish-intent scan (0.14.6 §1.2/§1.3): claims parked publish intents for
|
|
1919
|
+
// the session-carrying tick worker's direct issuance. One atomic
|
|
1920
|
+
// transaction per scan: claims and terminal notes all land together or not
|
|
1921
|
+
// at all.
|
|
1897
1922
|
//
|
|
1898
1923
|
// latest publish: note this scan
|
|
1899
|
-
// "
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
1911
|
-
// intent, 2026-09-20 blocker 22) the edit directly in its own turn (no child).
|
|
1912
|
-
// An issuer-written "submitted" already present →
|
|
1913
|
-
// mirror verification-requested, NEVER re-issue.
|
|
1914
|
-
// "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it); expired:
|
|
1915
|
-
// re-claim → intent (the tick runs the
|
|
1916
|
-
// manifest-freshness check first)
|
|
1917
|
-
// terminal notes (the closed registry in → terminal; skipped with the
|
|
1918
|
-
// lib/publish-note-vocabulary.js — the reason named, never
|
|
1919
|
-
// scan names the recognized note) "unrecognized"
|
|
1920
|
-
// "publish: verification-requested" → already routed; ignored (the verification scan owns it)
|
|
1924
|
+
// "publish: publish-requested …" → atomic 1h claim → intent bucket:
|
|
1925
|
+
// the tick issues the edit directly
|
|
1926
|
+
// in its own turn (no child).
|
|
1927
|
+
// "publish: publish-intent-claimed …" → unexpired: skip (a tick owns it);
|
|
1928
|
+
// expired: re-claim → intent (the tick
|
|
1929
|
+
// checks the dead attempt's version
|
|
1930
|
+
// receipt before issuing — never
|
|
1931
|
+
// blindly re-issues).
|
|
1932
|
+
// "publish: edit-issued …" → already routed; ignored (the ack
|
|
1933
|
+
// scan owns it from here).
|
|
1934
|
+
// terminal notes (the closed registry) → terminal; skipped with the
|
|
1935
|
+
// reason named, never "unrecognized".
|
|
1921
1936
|
//
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
// Fail closed, never guess.
|
|
1926
|
-
commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
1937
|
+
// The tick never issues without a claim, and a claim never outlives its
|
|
1938
|
+
// lease. Fail closed, never guess.
|
|
1939
|
+
commands["scan-publish-intent"] = (db, args, ctx) => {
|
|
1927
1940
|
const crewHome = ctx.crewHome;
|
|
1928
1941
|
const nowMs = Date.now();
|
|
1929
|
-
const quiesceMs = 30 * 60 * 1000; // design §1.4e: never trust a read before the park is 30m old
|
|
1930
1942
|
const claimLeaseMs = 60 * 60 * 1000; // one hour — survives a dead tick, does not block the next one
|
|
1931
|
-
const retrySettleMs = 20 * 60 * 1000; // a re-triggered build gets 20m before verification may claim it
|
|
1932
1943
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1933
1944
|
|
|
1934
|
-
const waiting = [];
|
|
1935
|
-
const due = [];
|
|
1936
1945
|
const intent = [];
|
|
1937
|
-
const retryDue = [];
|
|
1938
|
-
const mirrored = [];
|
|
1939
1946
|
const skipped = [];
|
|
1940
|
-
const requeued = []; //
|
|
1941
|
-
|
|
1942
|
-
const skip = (taskId, state, reason) => skipped.push({ task_id: taskId, state, ...(reason ? { reason } : {}) });
|
|
1943
|
-
|
|
1944
|
-
// Shared tail of the unknown branches: derive anchors, check the trigger
|
|
1945
|
-
// went out, find the trigger anchor, write the atomic claim, emit the
|
|
1946
|
-
// classifier inputs. parkNote is the /publish outcome unknown/i note.
|
|
1947
|
-
const claimForClassification = (taskId, notes, parkNote) => {
|
|
1948
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
1949
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); return; }
|
|
1950
|
-
if (!triggerWasIssued(derived.unknownEntry)) {
|
|
1951
|
-
skip(taskId, "trigger-never-issued",
|
|
1952
|
-
"the unknown ledger entry records that the trigger was never issued — no dropped edit to recover; stays parked for human attention");
|
|
1953
|
-
return;
|
|
1954
|
-
}
|
|
1955
|
-
const trigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
|
|
1956
|
-
// D1 (2026-09-19): the anchor instant is the trigger-issuance instant
|
|
1957
|
-
// (issued_at), not the ledger-write instant (ts) — issued_at || ts.
|
|
1958
|
-
if (!trigger || !(trigger.issued_at || trigger.ts)) {
|
|
1959
|
-
skip(taskId, "no-trigger-anchor",
|
|
1960
|
-
"no submitted ledger entry for this attempt — the trigger instant is unknowable; stays parked for human attention");
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
// Atomic claim: the note IS the claim, written inside this transaction
|
|
1964
|
-
// only while the latest publish: note is still the expected one (the
|
|
1965
|
-
// caller re-checks freshness before invoking this).
|
|
1966
|
-
const expiry = new Date(nowMs + claimLeaseMs).toISOString();
|
|
1967
|
-
writePublishNote(db, taskId,
|
|
1968
|
-
`publish: unknown-recovery-claimed ${expiry} — ${taskId} parked unknown at ${parkNote.timestamp}; claimed for classification (1-hour lease).`,
|
|
1969
|
-
new Date(nowMs).toISOString());
|
|
1970
|
-
// Classifier base: the last verified publish, or the empty tree when
|
|
1971
|
-
// nothing is stamped yet (a first publish) — same convention as the
|
|
1972
|
-
// Step 4.5 read-back sensor.
|
|
1973
|
-
// 2026-09-18, should-fix 4: provenance query failures fail CLOSED —
|
|
1974
|
-
// the empty tree is valid only when there is truly no provenance value.
|
|
1975
|
-
// A query error (locked DB, I/O failure) is not "no provenance"; skip
|
|
1976
|
-
// the task so the next scan retries.
|
|
1977
|
-
let base;
|
|
1978
|
-
try {
|
|
1979
|
-
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(derived.projectId);
|
|
1980
|
-
if (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit)) {
|
|
1981
|
-
base = prov.provenance_source_commit;
|
|
1982
|
-
} else {
|
|
1983
|
-
base = EMPTY_TREE; // truly no provenance value: first publish
|
|
1984
|
-
}
|
|
1985
|
-
} catch (e) {
|
|
1986
|
-
skip(taskId, "provenance-query-failed",
|
|
1987
|
-
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
due.push({
|
|
1991
|
-
task_id: taskId, commit: derived.commit, base,
|
|
1992
|
-
trigger_ts: trigger.issued_at || trigger.ts, park_ts: parkNote.timestamp,
|
|
1993
|
-
slug: derived.slug, repo_path: derived.repoPath,
|
|
1994
|
-
project_id: derived.projectId, claim_expiry: expiry,
|
|
1995
|
-
ledger_path: derived.ledgerPath,
|
|
1996
|
-
// Pre-trigger manifest baseline (design §1.9): the workflow snapshots
|
|
1997
|
-
// the manifest before the trigger into the submitted ledger entry; the
|
|
1998
|
-
// classifier's verified path compares the current manifest against it.
|
|
1999
|
-
// Null when the workflow predates the capture (fail closed downstream).
|
|
2000
|
-
manifest_before: (trigger.manifest_before && typeof trigger.manifest_before === "object")
|
|
2001
|
-
? trigger.manifest_before : null,
|
|
2002
|
-
});
|
|
2003
|
-
};
|
|
1947
|
+
const requeued = []; // stale-base intents re-queued to the workflow's Publish for re-preparation
|
|
2004
1948
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
//
|
|
2009
|
-
//
|
|
2010
|
-
//
|
|
1949
|
+
const skip = (taskId, reason, detail) =>
|
|
1950
|
+
skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
|
|
1951
|
+
|
|
1952
|
+
// One-party publish intent: the workflow parked at intent — the merged
|
|
1953
|
+
// change is staged as a checksummed diff carrying the per-attempt version,
|
|
1954
|
+
// and issuance belongs to the session-carrying tick worker. The intent
|
|
1955
|
+
// ledger entry (outcome "publish-intent") is the durable anchor: the tick
|
|
1956
|
+
// verifies the staged diff's sha256 against it and regenerates from
|
|
2011
1957
|
// base..commit when the file is missing. reclaimed=true when a previous
|
|
2012
|
-
// tick's claim expired: the tick
|
|
2013
|
-
// issuing, because the dead tick may have issued and died before
|
|
1958
|
+
// tick's claim expired: the tick checks the dead attempt's version receipt
|
|
1959
|
+
// before issuing, because the dead tick may have issued and died before
|
|
2014
1960
|
// recording it — a re-claim never blindly re-issues.
|
|
2015
1961
|
const claimForIntent = (taskId, intentNote, reclaimed, anchorNote) => {
|
|
2016
|
-
const m = intentNote.message.match(/publish
|
|
1962
|
+
const m = intentNote.message.match(/publish:\s*publish-requested\s+([0-9a-f]{40})\s+attempt=(\d+)/);
|
|
2017
1963
|
if (!m) {
|
|
2018
1964
|
skip(taskId, "intent-note-unparseable",
|
|
2019
|
-
"the publish-requested note carries no commit hash — stays parked for human attention");
|
|
1965
|
+
"the publish-requested note carries no commit hash + attempt=N — stays parked for human attention");
|
|
2020
1966
|
return;
|
|
2021
1967
|
}
|
|
2022
1968
|
const commit = m[1];
|
|
1969
|
+
const publishAttempt = parseInt(m[2], 10);
|
|
2023
1970
|
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2024
1971
|
if (!task || task.state !== "parked") {
|
|
2025
|
-
skip(taskId, "not-parked",
|
|
1972
|
+
skip(taskId, "not-parked",
|
|
1973
|
+
`task is not parked (state=${task && task.state}) — the intent claim requires a parked task`);
|
|
2026
1974
|
return;
|
|
2027
1975
|
}
|
|
2028
1976
|
const project = db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
@@ -2031,36 +1979,32 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2031
1979
|
skip(taskId, "no-deploy-slug", "project has no deploy_slug — stays parked for human attention");
|
|
2032
1980
|
return;
|
|
2033
1981
|
}
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
// task stays parked for human attention — no claim, no tick work.
|
|
1982
|
+
// A null repo_path fails closed at emission: the tick can neither
|
|
1983
|
+
// regenerate nor verify the diff. No claim, no tick work.
|
|
2037
1984
|
const repoPath = project && project.repo_path;
|
|
2038
1985
|
if (!repoPath) {
|
|
2039
1986
|
skip(taskId, "no-repo-path",
|
|
2040
1987
|
"project has no repo_path — the intent diff cannot be regenerated or verified; fails closed at emission; stays parked for human attention");
|
|
2041
1988
|
return;
|
|
2042
1989
|
}
|
|
2043
|
-
const
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2047
|
-
.filter((l) => l.trim().length > 0)
|
|
2048
|
-
.map((l) => JSON.parse(l))
|
|
2049
|
-
.filter((e) => e.task_id === taskId);
|
|
2050
|
-
} catch (e) {
|
|
2051
|
-
skip(taskId, "ledger-unreadable", `cannot read publish ledger: ${e.message} — stays parked for human attention`);
|
|
1990
|
+
const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
|
|
1991
|
+
if (!ledger.ok) {
|
|
1992
|
+
skip(taskId, "ledger-unreadable", `${ledger.reason} — stays parked for human attention`);
|
|
2052
1993
|
return;
|
|
2053
1994
|
}
|
|
2054
|
-
|
|
2055
|
-
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
1995
|
+
const intentEntry = findIntentEntry(ledger.entries, commit, publishAttempt);
|
|
2056
1996
|
if (!intentEntry) {
|
|
2057
1997
|
skip(taskId, "no-intent-entry",
|
|
2058
|
-
"no publish-intent ledger entry for this task — the intent anchor is missing; stays parked for human attention");
|
|
1998
|
+
"no publish-intent ledger entry for this task/commit/attempt — the intent anchor is missing; stays parked for human attention");
|
|
2059
1999
|
return;
|
|
2060
2000
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2001
|
+
// The version is protocol: the ledger is advisory — the deterministic
|
|
2002
|
+
// derivation is the contract. A mismatch means the staged diff was not
|
|
2003
|
+
// built by this machine; never issue it.
|
|
2004
|
+
const expectedVersion = derivePublishVersion(commit, publishAttempt);
|
|
2005
|
+
if (intentEntry.version !== expectedVersion) {
|
|
2006
|
+
skip(taskId, "intent-version-mismatch",
|
|
2007
|
+
"the intent entry's version is missing or does not match the deterministic derivation — stays parked for human attention");
|
|
2064
2008
|
return;
|
|
2065
2009
|
}
|
|
2066
2010
|
if (typeof intentEntry.diff_path !== "string" || !intentEntry.diff_path ||
|
|
@@ -2069,35 +2013,15 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2069
2013
|
"the intent entry carries no usable diff_path/diff_sha256 — stays parked for human attention");
|
|
2070
2014
|
return;
|
|
2071
2015
|
}
|
|
2072
|
-
//
|
|
2073
|
-
//
|
|
2074
|
-
//
|
|
2075
|
-
// arbiter (the same rule as the retry-intended crash window).
|
|
2076
|
-
const issued = entries.some((e) =>
|
|
2077
|
-
e.outcome === "submitted" && e.issuer === "tick-worker" &&
|
|
2078
|
-
e.commit === commit && (e.attempt || null) === (intentEntry.attempt || null));
|
|
2079
|
-
if (issued) {
|
|
2080
|
-
writePublishNote(db, taskId,
|
|
2081
|
-
`publish: verification-requested ${commit} attempt=${intentEntry.attempt} — mirrored by scan-publish-unknown: a tick-worker issuance entry exists without a mirrored request (the tick died between the writes). Waiting on the manual read-back in docs/publish-verification.md.`);
|
|
2082
|
-
mirrored.push({ task_id: taskId, commit, attempt: intentEntry.attempt || null, not_before: null, reason: "intent-issued-without-mirror" });
|
|
2083
|
-
return;
|
|
2084
|
-
}
|
|
2085
|
-
// Freshness CAS: the latest publish: note must still be the note we
|
|
2086
|
-
// branched on — for a first claim that is the intent note; for a
|
|
2087
|
-
// re-claim it is the expired claim note.
|
|
2016
|
+
// Freshness CAS: the latest publish: note must still be the anchor note
|
|
2017
|
+
// — for a first claim that is the intent note; for a re-claim it is the
|
|
2018
|
+
// expired claim note.
|
|
2088
2019
|
const fresh = latestPublishNote(db, taskId);
|
|
2089
|
-
if (!fresh ||
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
writePublishNote(db, taskId,
|
|
2095
|
-
`publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit}; claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
|
|
2096
|
-
claimedAt);
|
|
2097
|
-
// The classifier-base convention: last verified publish, or the empty
|
|
2098
|
-
// tree on a first publish — the tick needs it to regenerate the diff
|
|
2099
|
-
// byte-identically when the staged file is missing. A query error is
|
|
2100
|
-
// not "no provenance" — fail closed.
|
|
2020
|
+
if (!fresh || fresh.message !== anchorNote.message) { skip(taskId, "raced"); return; }
|
|
2021
|
+
// The classifier-base convention: last acknowledged publish, or the
|
|
2022
|
+
// empty tree on a first publish — the tick needs it to regenerate the
|
|
2023
|
+
// diff byte-identically when the staged file is missing. A query error
|
|
2024
|
+
// is not "no provenance" — fail closed.
|
|
2101
2025
|
let base;
|
|
2102
2026
|
try {
|
|
2103
2027
|
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
@@ -2111,47 +2035,39 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2111
2035
|
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
2112
2036
|
return;
|
|
2113
2037
|
}
|
|
2114
|
-
//
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
2120
|
-
// re-queue exactly once, then re-prepare with a base.
|
|
2038
|
+
// The intent's recorded base must match stamped provenance: another
|
|
2039
|
+
// publish may have landed after the intent was staged. A stale base
|
|
2040
|
+
// means the staged diff is against a superseded base — re-queue the
|
|
2041
|
+
// task (next_phase routes it straight back to the workflow's Publish,
|
|
2042
|
+
// which re-prepares the intent against the new base); never issue the
|
|
2043
|
+
// stale diff. Intents with no recorded base re-queue the same way.
|
|
2121
2044
|
const intentBase = (typeof intentEntry.base === "string" && /^[0-9a-f]{40}$/.test(intentEntry.base))
|
|
2122
2045
|
? intentEntry.base : null;
|
|
2123
2046
|
if (!intentBase || intentBase !== base) {
|
|
2124
|
-
|
|
2047
|
+
const staleAt = new Date(nowMs).toISOString();
|
|
2048
|
+
writeGuardedPublishNote(db, taskId,
|
|
2125
2049
|
`publish: publish-base-stale ${commit} — the intent's staged diff was computed against base ` +
|
|
2126
2050
|
`${intentBase ? intentBase.slice(0, 7) : "(none recorded)"} but stamped provenance now reads ` +
|
|
2127
|
-
`${base.slice(0, 7)}; issuing the stale diff risks a spurious
|
|
2051
|
+
`${base.slice(0, 7)}; issuing the stale diff risks a spurious mismatch. Re-queued: ` +
|
|
2128
2052
|
`the workflow's Publish re-prepares the intent against the new base.`,
|
|
2129
|
-
|
|
2053
|
+
staleAt);
|
|
2130
2054
|
db.prepare("UPDATE tasks SET next_phase = 'Publish', state = 'in_progress', updated_at = ? WHERE id = ?")
|
|
2131
2055
|
.run(now(), taskId);
|
|
2132
2056
|
requeued.push({ task_id: taskId, intent_base: intentBase, provenance_base: base });
|
|
2133
2057
|
return;
|
|
2134
2058
|
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
`publish: intent-unverifiable ${commit} — one-party intent terminal: this re-claim arrived with ` +
|
|
2142
|
-
`no pre-issuance manifest baseline, so the freshness comparison is unperformable; the tick can ` +
|
|
2143
|
-
`neither prove a dead tick's issuance nor safely re-issue. Parked for human attention.`,
|
|
2144
|
-
claimedAt);
|
|
2145
|
-
skip(taskId, "terminal",
|
|
2146
|
-
"re-claimed intent with a null manifest baseline — 'publish: intent-unverifiable'; parked for human attention");
|
|
2147
|
-
return;
|
|
2148
|
-
}
|
|
2059
|
+
const claimedAt = new Date(nowMs).toISOString();
|
|
2060
|
+
const expiry = new Date(nowMs + claimLeaseMs).toISOString();
|
|
2061
|
+
writeGuardedPublishNote(db, taskId,
|
|
2062
|
+
`publish: publish-intent-claimed ${expiry} — ${taskId} parked at publish intent for ${commit} ` +
|
|
2063
|
+
`(attempt ${publishAttempt}); claimed for direct issuance by the session-carrying tick worker (1-hour lease).`,
|
|
2064
|
+
claimedAt);
|
|
2149
2065
|
intent.push({
|
|
2150
|
-
task_id: taskId, commit, attempt:
|
|
2066
|
+
task_id: taskId, commit, attempt: publishAttempt, version: expectedVersion,
|
|
2151
2067
|
slug, repo_path: repoPath,
|
|
2152
|
-
project_id: project.id, ledger_path: ledgerPath, base,
|
|
2068
|
+
project_id: project.id, ledger_path: ledger.ledgerPath, base,
|
|
2153
2069
|
diff_path: intentEntry.diff_path, diff_sha256: intentEntry.diff_sha256,
|
|
2154
|
-
|
|
2070
|
+
files: Array.isArray(intentEntry.files) ? intentEntry.files : null,
|
|
2155
2071
|
claimed_at: claimedAt, claim_expiry: expiry, reclaimed,
|
|
2156
2072
|
});
|
|
2157
2073
|
};
|
|
@@ -2171,288 +2087,173 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
|
|
|
2171
2087
|
const latest = notes[0];
|
|
2172
2088
|
const msg = latest.message;
|
|
2173
2089
|
const has = (s) => msg.includes(s);
|
|
2174
|
-
const historyHas = (s) => notes.some((n) => n.message.includes(s));
|
|
2175
2090
|
|
|
2176
|
-
// Terminal states — the machine never revisits these. The vocabulary
|
|
2177
|
-
// explicit state (lib/publish-note-vocabulary.js); a recognized
|
|
2178
|
-
// note is skipped as terminal with its meaning, never as
|
|
2091
|
+
// Terminal states — the machine never revisits these. The vocabulary
|
|
2092
|
+
// is explicit state (lib/publish-note-vocabulary.js); a recognized
|
|
2093
|
+
// terminal note is skipped as terminal with its meaning, never as
|
|
2094
|
+
// unrecognized.
|
|
2179
2095
|
const terminalNote = matchTerminalPublishNote(msg);
|
|
2180
2096
|
if (terminalNote) {
|
|
2181
2097
|
skip(taskId, "terminal",
|
|
2182
2098
|
`recognized terminal note '${terminalNote}' — ${TERMINAL_NOTE_MEANINGS[terminalNote]}`);
|
|
2183
2099
|
continue;
|
|
2184
2100
|
}
|
|
2185
|
-
// Already
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
skip(taskId, "routed-to-verification");
|
|
2101
|
+
// Already issued — the acknowledgement scan owns the task from here.
|
|
2102
|
+
if (has("publish: edit-issued")) {
|
|
2103
|
+
skip(taskId, "routed-to-ack-scan");
|
|
2189
2104
|
continue;
|
|
2190
2105
|
}
|
|
2191
|
-
// A
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
|
|
2195
|
-
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2196
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2197
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2198
|
-
writePublishNote(db, taskId,
|
|
2199
|
-
`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.`);
|
|
2200
|
-
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: null, reason: "retry-issued-without-mirror" });
|
|
2201
|
-
continue;
|
|
2202
|
-
}
|
|
2203
|
-
// A retry-intended note with no retry-issued: the tick died mid-retry
|
|
2204
|
-
// and the trigger may or may not have gone out. NEVER re-trigger here —
|
|
2205
|
-
// route to the verification pipeline and let content verification be
|
|
2206
|
-
// the arbiter (design §1.5 R3).
|
|
2207
|
-
if (has("publish: retry-intended")) {
|
|
2208
|
-
if (historyHas("publish: verification-requested")) { skip(taskId, "raced"); continue; }
|
|
2209
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2210
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2211
|
-
const m = msg.match(/publish: retry-intended\s+[0-9a-f]{40}\s+(\S+)/);
|
|
2212
|
-
const intendedMs = m ? Date.parse(m[1]) : NaN;
|
|
2213
|
-
const notBefore = new Date(Math.max(nowMs, (intendedMs > 0 ? intendedMs : nowMs) + retrySettleMs)).toISOString();
|
|
2214
|
-
writePublishNote(db, taskId,
|
|
2215
|
-
`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.`);
|
|
2216
|
-
mirrored.push({ task_id: taskId, commit: derived.commit, attempt: derived.attempt, not_before: notBefore, reason: "retry-intended-without-retry-issued" });
|
|
2217
|
-
continue;
|
|
2218
|
-
}
|
|
2219
|
-
// Classified as dropped and awaiting the retry protocol. Exactly one
|
|
2220
|
-
// retry, enforced from note events (design §1.5): a retry-issued
|
|
2221
|
-
// anywhere in this task's history spends the budget — including a
|
|
2222
|
-
// retry for an earlier unknown attempt on a reworked task. When the
|
|
2223
|
-
// budget is spent the drop is terminal: ambiguous, human attention.
|
|
2224
|
-
if (has("publish: dropped")) {
|
|
2225
|
-
const derived = derivePublishUnknownAttempt(db, crewHome, taskId);
|
|
2226
|
-
if (!derived.ok) { skip(taskId, "derive-failed", derived.reason); continue; }
|
|
2227
|
-
if (historyHas("publish: retry-issued")) {
|
|
2228
|
-
writePublishNote(db, taskId,
|
|
2229
|
-
`publish: ambiguous ${derived.commit} — retry budget exhausted: a publish retry was already issued for this task in an earlier attempt; not retrying blind. Parked for human attention.`);
|
|
2230
|
-
try {
|
|
2231
|
-
appendFileSync(derived.ledgerPath, JSON.stringify({
|
|
2232
|
-
ts: now(), task_id: taskId, workflow: "unknown-recovery", slug: derived.slug,
|
|
2233
|
-
commit: derived.commit, attempt: derived.attempt, agent_id: null,
|
|
2234
|
-
applied_report: null, outcome: "unknown-classified",
|
|
2235
|
-
detail: "retry budget exhausted — ambiguous, terminal",
|
|
2236
|
-
}) + "\n");
|
|
2237
|
-
} catch (e) { /* best-effort observability */ }
|
|
2238
|
-
continue;
|
|
2239
|
-
}
|
|
2240
|
-
// Retry budget free: queue the retry protocol. The tick needs the
|
|
2241
|
-
// classifier base to regenerate the publish diff byte-identically
|
|
2242
|
-
// (compute-publish-diff.js --base), so ferry it from provenance.
|
|
2243
|
-
// The in-lock re-check also needs the trigger anchor and the
|
|
2244
|
-
// pre-trigger manifest baseline (design §1.9).
|
|
2245
|
-
const retryProv = db.prepare(
|
|
2246
|
-
`SELECT provenance_source_commit FROM projects WHERE id = ?`
|
|
2247
|
-
).get(derived.projectId);
|
|
2248
|
-
const retryBase = (retryProv && retryProv.provenance_source_commit) || EMPTY_TREE;
|
|
2249
|
-
const retryTrigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
|
|
2250
|
-
retryDue.push({
|
|
2251
|
-
task_id: taskId, commit: derived.commit, attempt: derived.attempt,
|
|
2252
|
-
slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
|
|
2253
|
-
ledger_path: derived.ledgerPath, base: retryBase,
|
|
2254
|
-
trigger_ts: (retryTrigger && (retryTrigger.issued_at || retryTrigger.ts)) || null,
|
|
2255
|
-
manifest_before: (retryTrigger && retryTrigger.manifest_before &&
|
|
2256
|
-
typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
|
|
2257
|
-
});
|
|
2258
|
-
continue;
|
|
2259
|
-
}
|
|
2260
|
-
// One-party publish intent (2026-09-20, blocker 22): the workflow
|
|
2261
|
-
// parked at intent; the session-carrying tick worker issues the edit
|
|
2262
|
-
// directly from the intent bucket — no child, no trigger.
|
|
2106
|
+
// A previous tick claimed this intent. Unexpired: a tick owns it.
|
|
2107
|
+
// Expired: the tick died mid-issuance — re-derive from the original
|
|
2108
|
+
// intent note and re-claim; the re-claiming tick checks the dead
|
|
2109
|
+
// attempt's version receipt before issuing, never blindly re-issues.
|
|
2263
2110
|
if (has("publish: publish-intent-claimed")) {
|
|
2264
|
-
const
|
|
2265
|
-
const expiryMs =
|
|
2111
|
+
const cm = msg.match(/publish: publish-intent-claimed\s+(\S+)/);
|
|
2112
|
+
const expiryMs = cm ? Date.parse(cm[1]) : NaN;
|
|
2266
2113
|
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
2267
|
-
// Expired claim: the tick died mid-issuance. Re-derive from the
|
|
2268
|
-
// original intent note and re-claim — the re-claiming tick runs the
|
|
2269
|
-
// manifest-freshness check before issuing, never blindly re-issues.
|
|
2270
2114
|
const intentNote = notes.find((n) => n.message.includes("publish: publish-requested"));
|
|
2271
|
-
if (!intentNote) {
|
|
2115
|
+
if (!intentNote) {
|
|
2116
|
+
skip(taskId, "no-intent-note", "expired intent claim with no publish-requested note in history");
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
const fresh = latestPublishNote(db, taskId);
|
|
2120
|
+
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2272
2121
|
claimForIntent(taskId, intentNote, true, latest);
|
|
2273
2122
|
continue;
|
|
2274
2123
|
}
|
|
2124
|
+
// The workflow's parked intent.
|
|
2275
2125
|
if (has("publish: publish-requested")) {
|
|
2276
2126
|
claimForIntent(taskId, latest, false, latest);
|
|
2277
2127
|
continue;
|
|
2278
2128
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
if (has("publish: unknown-recovery-claimed")) {
|
|
2282
|
-
const m = msg.match(/publish: unknown-recovery-claimed\s+(\S+)/);
|
|
2283
|
-
const expiryMs = m ? Date.parse(m[1]) : NaN;
|
|
2284
|
-
if (expiryMs > 0 && nowMs <= expiryMs) { skip(taskId, "claimed"); continue; }
|
|
2285
|
-
// Expired claim: the tick died mid-classification. Re-derive from
|
|
2286
|
-
// the original unknown note and re-claim.
|
|
2287
|
-
const parkNote = notes.find((n) => /publish outcome unknown/i.test(n.message));
|
|
2288
|
-
if (!parkNote) { skip(taskId, "no-unknown-note", "expired claim with no unknown park note in history"); continue; }
|
|
2289
|
-
const fresh = latestPublishNote(db, taskId);
|
|
2290
|
-
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2291
|
-
claimForClassification(taskId, notes, parkNote);
|
|
2292
|
-
continue;
|
|
2293
|
-
}
|
|
2294
|
-
// The initial unknown park note. Quiescence first: the classifier must
|
|
2295
|
-
// never read a manifest younger than 30 minutes past the park.
|
|
2296
|
-
if (/publish outcome unknown/i.test(msg)) {
|
|
2297
|
-
const parkMs = Date.parse(latest.timestamp);
|
|
2298
|
-
if (!(parkMs > 0) || nowMs < parkMs + quiesceMs) {
|
|
2299
|
-
waiting.push({ task_id: taskId, parked_at: latest.timestamp });
|
|
2300
|
-
continue;
|
|
2301
|
-
}
|
|
2302
|
-
// Re-check freshness inside the transaction: the latest publish:
|
|
2303
|
-
// note must still be this unknown note.
|
|
2304
|
-
const fresh = latestPublishNote(db, taskId);
|
|
2305
|
-
if (!fresh || fresh.message !== msg) { skip(taskId, "raced"); continue; }
|
|
2306
|
-
claimForClassification(taskId, notes, latest);
|
|
2307
|
-
continue;
|
|
2308
|
-
}
|
|
2309
|
-
skip(taskId, "unrecognized-publish-note");
|
|
2129
|
+
skip(taskId, "unrecognized-publish-note",
|
|
2130
|
+
`latest publish note is not part of the 0.14.6 machine — stays parked for human attention: ${msg.slice(0, 120)}`);
|
|
2310
2131
|
}
|
|
2311
2132
|
db.exec("COMMIT");
|
|
2312
2133
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2313
2134
|
|
|
2314
|
-
return {
|
|
2135
|
+
return { intent, skipped, requeued };
|
|
2315
2136
|
};
|
|
2316
|
-
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
// turn (no child — blocker 22 proved children cannot reach artifact_edit)
|
|
2320
|
-
// and ferries the outcome here. Code decides: every note and ledger line
|
|
2321
|
-
// the outcome implies is written here, atomically, never free-formed by
|
|
2322
|
-
// the tick.
|
|
2137
|
+
// Record a one-party intent issuance (0.14.6 §1.2): the session-carrying
|
|
2138
|
+
// tick worker calls this exactly once per artifact_edit call that completed
|
|
2139
|
+
// without error and without an explicit refusal.
|
|
2323
2140
|
//
|
|
2324
|
-
// Args: { task_id, claim_expiry, edit_outcome,
|
|
2325
|
-
//
|
|
2326
|
-
// without error and without an explicit refusal — and ONLY then; tool
|
|
2327
|
-
// unavailable, timeout, or any ambiguous result is not accepted and is
|
|
2328
|
-
// never recorded here, the tick logs it and lets the claim expire),
|
|
2329
|
-
// "recovered" (a re-claiming tick ran lib/check-intent-freshness.js and the
|
|
2330
|
-
// manifest advanced past the intent's baseline — the dead tick issued and
|
|
2331
|
-
// died before recording it), or "refused" (the platform explicitly refused
|
|
2332
|
-
// the edit).
|
|
2141
|
+
// Args: { task_id, claim_expiry, edit_outcome: "accepted"|"refused",
|
|
2142
|
+
// refusal_text? } — omit refusal_text unless refused.
|
|
2333
2143
|
//
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
// writes
|
|
2144
|
+
// `accepted` writes the issuer-stamped `submitted` ledger entry (carrying
|
|
2145
|
+
// the attempt's version) and mirrors `publish: edit-issued` — the ack scan
|
|
2146
|
+
// claims the task on a later tick and evaluates acknowledgement evidence.
|
|
2147
|
+
// `refused` writes `rejected` and the terminal `publish: publish-refused`
|
|
2148
|
+
// note — no re-issue, ever.
|
|
2338
2149
|
//
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
//
|
|
2342
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
// note for this commit — which lower-bounds the unobserved issuance
|
|
2345
|
-
// instant, 2026-09-20 critic-0145 F-O1) + the tick's freshness evidence
|
|
2346
|
-
// recorded on the "recovered" entry + mirrored
|
|
2347
|
-
// `publish: verification-requested` (the verification scan claims it).
|
|
2348
|
-
// refused → issuer-written "rejected" ledger entry carrying
|
|
2349
|
-
// the platform's refusal text + terminal `publish: publish-refused`
|
|
2350
|
-
// (parked for human attention — no re-issue, no park-unknown).
|
|
2150
|
+
// There is deliberately NO outcome for an inconclusive edit (timeout,
|
|
2151
|
+
// transport error, tool unavailable): the tick records NOTHING then — the
|
|
2152
|
+
// claim expires, the next scan re-claims, and the re-claiming tick's version
|
|
2153
|
+
// check decides. An unrecorded outcome is the only honest record of an
|
|
2154
|
+
// unknown one.
|
|
2351
2155
|
//
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
//
|
|
2156
|
+
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
2157
|
+
// latest publish: note is still this tick's intent claim. A stale tick gets
|
|
2158
|
+
// { recorded: false } and writes nothing.
|
|
2159
|
+
// Shared preamble for the tick worker's claim-guarded record commands
|
|
2160
|
+
// (record-intent-issuance, record-intent-unissuable, 2026-09-20 REVIEW):
|
|
2161
|
+
// verifies the CAS claim, resolves the project's deploy slug, reads the
|
|
2162
|
+
// task's publish ledger, and locates the latest publish-intent entry.
|
|
2163
|
+
// Returns { ok: false, reason } when the command must not record, else
|
|
2164
|
+
// { ok: true, slug, ledgerPath, intentEntry, commit }. Callers run inside
|
|
2165
|
+
// their own BEGIN/COMMIT transaction and ROLLBACK on { ok: false }.
|
|
2166
|
+
function resolveClaimedIntent(db, ctx, taskId, claimExpiry) {
|
|
2167
|
+
// CAS: the latest publish: note must still be this tick's claim.
|
|
2168
|
+
const latest = latestPublishNote(db, taskId);
|
|
2169
|
+
if (!latest || !latest.message.includes("publish: publish-intent-claimed") ||
|
|
2170
|
+
!latest.message.includes(claimExpiry)) {
|
|
2171
|
+
return { ok: false, reason: "claim no longer current — another tick owns this task" };
|
|
2172
|
+
}
|
|
2173
|
+
const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
|
|
2174
|
+
const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2175
|
+
const slug = project && project.deploy_slug;
|
|
2176
|
+
if (!slug) return { ok: false, reason: "project has no deploy_slug" };
|
|
2177
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
2178
|
+
if (!ledger.ok) return { ok: false, reason: ledger.reason };
|
|
2179
|
+
const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2180
|
+
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
2181
|
+
return { ok: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
2182
|
+
}
|
|
2183
|
+
return { ok: true, slug, ledgerPath: ledger.ledgerPath, intentEntry, commit: intentEntry.commit };
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2355
2186
|
commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
2356
2187
|
const taskId = args.task_id;
|
|
2357
2188
|
const claimExpiry = args.claim_expiry;
|
|
2358
2189
|
const editOutcome = args.edit_outcome;
|
|
2359
2190
|
if (!taskId) throw usageError("task_id is required.");
|
|
2360
2191
|
if (!claimExpiry) throw usageError("claim_expiry is required.");
|
|
2361
|
-
if (!["accepted", "
|
|
2192
|
+
if (!["accepted", "refused"].includes(editOutcome)) {
|
|
2362
2193
|
throw usageError(`unknown edit outcome: ${editOutcome}.`);
|
|
2363
2194
|
}
|
|
2195
|
+
// Reclaimed-claim receipt guard (2026-09-21 R-3): a reclaimed claim
|
|
2196
|
+
// means a previous tick died mid-issuance and may have issued the edit
|
|
2197
|
+
// without recording it. The tick worker MUST run the step-2 receipt
|
|
2198
|
+
// check and report its outcome explicitly: "different" (receipt
|
|
2199
|
+
// readable but differs from this attempt's version), "unreadable"
|
|
2200
|
+
// (receipt missing or unreadable — NOT proof the edit did not land), or
|
|
2201
|
+
// "match" (receipt equals this attempt's version — the edit landed;
|
|
2202
|
+
// acknowledge it via record-version-ack, never re-issue). A missing or
|
|
2203
|
+
// unknown receipt_check on a reclaimed claim is a usage error; "match"
|
|
2204
|
+
// refuses to record (fail closed, no write) so a landed edit is never
|
|
2205
|
+
// re-issued. Non-reclaimed claims take no receipt_check.
|
|
2206
|
+
const reclaimed = args.reclaimed === true || args.reclaimed === "true";
|
|
2207
|
+
const receiptCheck = args.receipt_check;
|
|
2208
|
+
if (reclaimed) {
|
|
2209
|
+
if (!["different", "unreadable", "match"].includes(receiptCheck)) {
|
|
2210
|
+
throw usageError("receipt_check is required for a reclaimed claim (one of: different, unreadable, match).");
|
|
2211
|
+
}
|
|
2212
|
+
} else if (receiptCheck !== undefined && receiptCheck !== null) {
|
|
2213
|
+
throw usageError("receipt_check is only valid for a reclaimed claim.");
|
|
2214
|
+
}
|
|
2364
2215
|
|
|
2365
2216
|
db.exec("BEGIN");
|
|
2366
2217
|
try {
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2218
|
+
const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
|
|
2219
|
+
if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
|
|
2220
|
+
const { slug, ledgerPath, intentEntry, commit } = pre;
|
|
2221
|
+
if (reclaimed && receiptCheck === "match") {
|
|
2371
2222
|
db.exec("ROLLBACK");
|
|
2372
|
-
return { recorded: false, reason: "
|
|
2223
|
+
return { recorded: false, reason: "receipt already matches the claimed version — the edit landed; acknowledge via record-version-ack, never re-issue" };
|
|
2373
2224
|
}
|
|
2374
|
-
|
|
2375
|
-
const
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
if (!slug) {
|
|
2225
|
+
const publishAttempt = intentEntry.publish_attempt;
|
|
2226
|
+
const version = intentEntry.version;
|
|
2227
|
+
if (!Number.isInteger(publishAttempt) || publishAttempt < 1 ||
|
|
2228
|
+
version !== derivePublishVersion(commit, publishAttempt)) {
|
|
2379
2229
|
db.exec("ROLLBACK");
|
|
2380
|
-
return { recorded: false, reason: "
|
|
2381
|
-
}
|
|
2382
|
-
const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
|
|
2383
|
-
let entries = [];
|
|
2384
|
-
try {
|
|
2385
|
-
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2386
|
-
.filter((l) => l.trim().length > 0)
|
|
2387
|
-
.map((l) => JSON.parse(l))
|
|
2388
|
-
.filter((e) => e.task_id === taskId);
|
|
2389
|
-
} catch (e) {
|
|
2390
|
-
db.exec("ROLLBACK");
|
|
2391
|
-
return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
2392
|
-
}
|
|
2393
|
-
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2394
|
-
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
2395
|
-
db.exec("ROLLBACK");
|
|
2396
|
-
return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
2230
|
+
return { recorded: false, reason: "intent entry carries no usable publish_attempt/version — refusing to record" };
|
|
2397
2231
|
}
|
|
2398
2232
|
const ts = now();
|
|
2399
|
-
const commit = intentEntry.commit;
|
|
2400
|
-
const attempt = intentEntry.attempt || null;
|
|
2401
|
-
// (2026-09-20, critic-0145 F-O1) A recovered issuance's instant is
|
|
2402
|
-
// unobserved, but it is LOWER-BOUNDED by the dead tick's original
|
|
2403
|
-
// claim: the earliest publish-intent-claimed note for this commit (not
|
|
2404
|
-
// the reclaimer's later claim). Binding issued_at to that time keeps
|
|
2405
|
-
// the verifier's staleness check honest — the old null fell back to
|
|
2406
|
-
// the recovery record's later timestamp and made a genuine
|
|
2407
|
-
// already-completed build look stale.
|
|
2408
|
-
let recoveredIssuedAt = null;
|
|
2409
|
-
if (editOutcome === "recovered") {
|
|
2410
|
-
const deadClaim = db.prepare(
|
|
2411
|
-
`SELECT timestamp FROM events
|
|
2412
|
-
WHERE task_id = ? AND type = 'note' AND message LIKE '%publish: publish-intent-claimed%'
|
|
2413
|
-
AND message LIKE ?
|
|
2414
|
-
ORDER BY timestamp ASC LIMIT 1`
|
|
2415
|
-
).get(taskId, `%${commit}%`);
|
|
2416
|
-
recoveredIssuedAt = (deadClaim && deadClaim.timestamp) || null;
|
|
2417
|
-
}
|
|
2418
|
-
// (2026-09-20, critic-0145 F-O3/F-R2) A recovery must carry machine
|
|
2419
|
-
// evidence: the check-intent-freshness.js JSON with advanced:true. An
|
|
2420
|
-
// unproven recovery is not recorded — the command fails the call so the
|
|
2421
|
-
// tick cannot write an honest-looking "recovered" on prose.
|
|
2422
|
-
let freshnessEvidence = null;
|
|
2423
|
-
if (editOutcome === "recovered") {
|
|
2424
|
-
const ev = args.freshness_evidence;
|
|
2425
|
-
const evObj = typeof ev === "string" ? (() => { try { return JSON.parse(ev); } catch { return null; } })() : ev;
|
|
2426
|
-
if (!evObj || typeof evObj !== "object" || evObj.ok !== true || evObj.advanced !== true) {
|
|
2427
|
-
throw usageError("recovered requires freshness_evidence — the check-intent-freshness.js JSON with advanced:true.");
|
|
2428
|
-
}
|
|
2429
|
-
freshnessEvidence = evObj;
|
|
2430
|
-
}
|
|
2431
2233
|
const ledgerLine = {
|
|
2432
2234
|
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
2433
|
-
commit, attempt
|
|
2235
|
+
commit, attempt: intentEntry.attempt || null, publish_attempt: publishAttempt, version,
|
|
2236
|
+
agent_id: null, applied_report: null,
|
|
2434
2237
|
issuer: "tick-worker",
|
|
2435
2238
|
diff_sha256: intentEntry.diff_sha256 || null,
|
|
2436
|
-
|
|
2437
|
-
|
|
2239
|
+
issued_at: editOutcome === "accepted" ? ts : null,
|
|
2240
|
+
receipt_check: reclaimed ? receiptCheck : null,
|
|
2438
2241
|
};
|
|
2439
2242
|
let note;
|
|
2440
2243
|
if (editOutcome === "refused") {
|
|
2441
2244
|
const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
|
|
2442
2245
|
ledgerLine.outcome = "rejected";
|
|
2443
|
-
ledgerLine.
|
|
2444
|
-
|
|
2246
|
+
ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}` +
|
|
2247
|
+
(reclaimed ? ` (reclaimed claim; step-2 receipt check was '${receiptCheck}')` : "");
|
|
2445
2248
|
// Terminal: parked for human attention. "publish: publish-refused"
|
|
2446
2249
|
// is registered in lib/publish-note-vocabulary.js (the closed
|
|
2447
2250
|
// registry — the writer-side guard pattern from D7).
|
|
2448
|
-
note = `publish: publish-refused ${commit} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
|
|
2251
|
+
note = `publish: publish-refused ${commit} attempt=${publishAttempt} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
|
|
2449
2252
|
} else {
|
|
2450
2253
|
ledgerLine.outcome = "submitted";
|
|
2451
|
-
ledgerLine.
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
: `recovered: the manifest advanced past the intent's baseline (freshness evidence recorded) — the dead tick issued and died before recording it; issuance attributed to the dead tick, issued_at lower-bounded by the dead tick's original claim at ${recoveredIssuedAt || "unknown"}`;
|
|
2455
|
-
note = `publish: verification-requested ${commit} attempt=${attempt} — issued directly by the session-carrying tick worker (one-party publish). Waiting on the manual read-back in docs/publish-verification.md.`;
|
|
2254
|
+
ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt" +
|
|
2255
|
+
(reclaimed ? `; reclaimed claim, step-2 receipt check was '${receiptCheck}' (a differing or unreadable receipt is not proof the prior attempt's edit did not land)` : "");
|
|
2256
|
+
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.`;
|
|
2456
2257
|
}
|
|
2457
2258
|
try {
|
|
2458
2259
|
appendFileSync(ledgerPath, JSON.stringify(ledgerLine) + "\n");
|
|
@@ -2460,29 +2261,11 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
|
2460
2261
|
db.exec("ROLLBACK");
|
|
2461
2262
|
return { recorded: false, reason: `cannot append publish ledger: ${e.message}` };
|
|
2462
2263
|
}
|
|
2463
|
-
|
|
2264
|
+
writeGuardedPublishNote(db, taskId, note, ts);
|
|
2464
2265
|
db.exec("COMMIT");
|
|
2465
|
-
return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, note };
|
|
2266
|
+
return { recorded: true, edit_outcome: editOutcome, task_id: taskId, commit, publish_attempt: publishAttempt, note };
|
|
2466
2267
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2467
2268
|
};
|
|
2468
|
-
|
|
2469
|
-
// Record a one-party intent as unissuable (2026-09-20, critic-0145 F-O2).
|
|
2470
|
-
// The session-carrying tick worker calls this when the staged diff cannot
|
|
2471
|
-
// be (re)generated byte-identically — the staged file is missing and
|
|
2472
|
-
// compute-publish-diff --commit fails, or the regenerated hash does not
|
|
2473
|
-
// match the intent's diff_sha256. Code decides: the terminal
|
|
2474
|
-
// `publish: publish-unissuable` note and an issuer-written ledger entry
|
|
2475
|
-
// (outcome "unissuable", issuer "tick-worker") are written here,
|
|
2476
|
-
// atomically, never free-formed by the tick.
|
|
2477
|
-
//
|
|
2478
|
-
// Args: { task_id, claim_expiry, reason } where reason is a short
|
|
2479
|
-
// machine-readable cause (missing-staged-diff, diff-unregenerable,
|
|
2480
|
-
// hash-mismatch-after-regeneration).
|
|
2481
|
-
//
|
|
2482
|
-
// The claim_expiry is a compare-and-swap: the record lands only while the
|
|
2483
|
-
// latest publish: note is still this tick's intent claim. A stale tick gets
|
|
2484
|
-
// { recorded: false } and writes nothing. The terminal note means the scan
|
|
2485
|
-
// never re-claims — no hourly reclaim loop on an unissuable diff.
|
|
2486
2269
|
commands["record-intent-unissuable"] = (db, args, ctx) => {
|
|
2487
2270
|
const taskId = args.task_id;
|
|
2488
2271
|
const claimExpiry = args.claim_expiry;
|
|
@@ -2493,38 +2276,10 @@ commands["record-intent-unissuable"] = (db, args, ctx) => {
|
|
|
2493
2276
|
|
|
2494
2277
|
db.exec("BEGIN");
|
|
2495
2278
|
try {
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
!latest.message.includes(claimExpiry)) {
|
|
2500
|
-
db.exec("ROLLBACK");
|
|
2501
|
-
return { recorded: false, reason: "claim no longer current — another tick owns this task" };
|
|
2502
|
-
}
|
|
2503
|
-
const task = db.prepare("SELECT id, project FROM tasks WHERE id = ?").get(taskId);
|
|
2504
|
-
const project = task && db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2505
|
-
const slug = project && project.deploy_slug;
|
|
2506
|
-
if (!slug) {
|
|
2507
|
-
db.exec("ROLLBACK");
|
|
2508
|
-
return { recorded: false, reason: "project has no deploy_slug" };
|
|
2509
|
-
}
|
|
2510
|
-
const ledgerPath = join(ctx.crewHome, ".publish-ledger", slug + ".jsonl");
|
|
2511
|
-
let entries = [];
|
|
2512
|
-
try {
|
|
2513
|
-
entries = readFileSync(ledgerPath, "utf8").split("\n")
|
|
2514
|
-
.filter((l) => l.trim().length > 0)
|
|
2515
|
-
.map((l) => JSON.parse(l))
|
|
2516
|
-
.filter((e) => e.task_id === taskId);
|
|
2517
|
-
} catch (e) {
|
|
2518
|
-
db.exec("ROLLBACK");
|
|
2519
|
-
return { recorded: false, reason: `cannot read publish ledger: ${e.message}` };
|
|
2520
|
-
}
|
|
2521
|
-
const intentEntry = [...entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2522
|
-
if (!intentEntry || !/^[0-9a-f]{40}$/.test(intentEntry.commit || "")) {
|
|
2523
|
-
db.exec("ROLLBACK");
|
|
2524
|
-
return { recorded: false, reason: "no usable publish-intent ledger entry for this task" };
|
|
2525
|
-
}
|
|
2279
|
+
const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
|
|
2280
|
+
if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
|
|
2281
|
+
const { slug, ledgerPath, intentEntry, commit } = pre;
|
|
2526
2282
|
const ts = now();
|
|
2527
|
-
const commit = intentEntry.commit;
|
|
2528
2283
|
const attempt = intentEntry.attempt || null;
|
|
2529
2284
|
const ledgerLine = {
|
|
2530
2285
|
ts, task_id: taskId, workflow: "intent-issuance", slug,
|
|
@@ -2544,197 +2299,512 @@ commands["record-intent-unissuable"] = (db, args, ctx) => {
|
|
|
2544
2299
|
// lib/publish-note-vocabulary.js (the closed registry — the writer-side
|
|
2545
2300
|
// guard pattern from D7).
|
|
2546
2301
|
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.`;
|
|
2547
|
-
|
|
2302
|
+
writeGuardedPublishNote(db, taskId, note, ts);
|
|
2548
2303
|
db.exec("COMMIT");
|
|
2549
2304
|
return { recorded: true, task_id: taskId, commit, note };
|
|
2550
2305
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2551
2306
|
};
|
|
2552
|
-
|
|
2553
|
-
//
|
|
2554
|
-
//
|
|
2555
|
-
//
|
|
2556
|
-
// Code decides: every note the decision implies is written here, atomically,
|
|
2557
|
-
// never free-formed by the tick.
|
|
2307
|
+
// Acknowledgement scan (0.14.6 §1.3): evaluates acknowledgement evidence
|
|
2308
|
+
// for issued edits. Eric's contract: exact version acknowledgement is the
|
|
2309
|
+
// SOLE positive completion criterion — "If we hear that the artifact
|
|
2310
|
+
// acknowledges our version, that's it. We don't verify against content."
|
|
2558
2311
|
//
|
|
2559
|
-
//
|
|
2560
|
-
//
|
|
2312
|
+
// Per parked task whose latest publish: note is `publish: edit-issued`:
|
|
2313
|
+
// - the artifact's on-disk version receipt
|
|
2314
|
+
// (<spaces>/ts-spaces/<slug>/.crew-publish-version-<task_id>) equals ANY issued
|
|
2315
|
+
// version for this task+commit (a late earlier attempt counts), OR a
|
|
2316
|
+
// version-echoing builder report (outcome acknowledged) names one
|
|
2317
|
+
// → acknowledged: provenance stamped (certifying the request was
|
|
2318
|
+
// acknowledged, not byte equality), terminal
|
|
2319
|
+
// `publish: version-acknowledged`, task re-queued.
|
|
2320
|
+
// - else a builder report (outcome refused) names an issued version
|
|
2321
|
+
// → refused: terminal `publish: publish-refused`.
|
|
2322
|
+
// - else the 30-minute window from the LATEST issuance is still open
|
|
2323
|
+
// → skipped (window-open).
|
|
2324
|
+
// - else the 2-hour total budget from the FIRST issuance is not exhausted
|
|
2325
|
+
// → reissued: a fresh intent is staged with a fresh version,
|
|
2326
|
+
// immediately claimable (no backoff); the NEXT tick's intent scan
|
|
2327
|
+
// claims it. This tick issues nothing.
|
|
2328
|
+
// - else → version-timeout: terminal `publish: version-timeout`.
|
|
2561
2329
|
//
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
// nothing.
|
|
2330
|
+
// One atomic transaction per scan. The command is fully code-local — no
|
|
2331
|
+
// claim notes, no tick mediation. `spaces_root` arg overrides the ts-spaces
|
|
2332
|
+
// root (tests); production reads ~/workspace/ts-spaces.
|
|
2566
2333
|
//
|
|
2567
|
-
//
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
//
|
|
2574
|
-
//
|
|
2575
|
-
//
|
|
2576
|
-
//
|
|
2577
|
-
//
|
|
2578
|
-
//
|
|
2579
|
-
//
|
|
2580
|
-
//
|
|
2581
|
-
|
|
2582
|
-
const
|
|
2583
|
-
const
|
|
2584
|
-
const
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
const
|
|
2590
|
-
|
|
2334
|
+
// Returns { acknowledged, refused, reissued, timeouts, skipped }.
|
|
2335
|
+
commands["scan-ack-pending"] = (db, args, ctx) => {
|
|
2336
|
+
const crewHome = ctx.crewHome;
|
|
2337
|
+
const nowMs = Date.now();
|
|
2338
|
+
// (2026-09-20, Eric's call) 30-minute acknowledgement window per attempt,
|
|
2339
|
+
// 2-hour TOTAL budget from the FIRST issuance of the commit — then terminal
|
|
2340
|
+
// version-timeout. No backoff: a reissue staged at window expiry is
|
|
2341
|
+
// immediately claimable; the window expiry is the only spacing. The window
|
|
2342
|
+
// anchors at the LATEST issuance (each attempt gets its own window); the
|
|
2343
|
+
// budget anchors at the FIRST (the whole publish effort gets one budget,
|
|
2344
|
+
// however many attempts fit). The 30-minute window is evidence-set: room
|
|
2345
|
+
// #25's one healthy ack landed ~15-17 minutes after issuance — the window
|
|
2346
|
+
// is ~2x that plus margin. The ledger records issued_at and first-seen ack
|
|
2347
|
+
// for every attempt, so the real distribution is measurable and the window
|
|
2348
|
+
// is tunable from data, not from a placeholder.
|
|
2349
|
+
const ACK_WINDOW_MS = 30 * 60 * 1000;
|
|
2350
|
+
const TOTAL_BUDGET_MS = 2 * 60 * 60 * 1000;
|
|
2351
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2352
|
+
const spacesRoot = (args && args.spaces_root) || join(homedir(), "workspace", "ts-spaces");
|
|
2353
|
+
// The release running this scan must provably be the active release
|
|
2354
|
+
// before it stamps acknowledgement claims — fail the whole scan closed
|
|
2355
|
+
// on a stale cron body or mid-deploy swap.
|
|
2356
|
+
const releaseName = resolveActiveRelease(crewHome);
|
|
2357
|
+
const computeDiffScript = new URL("./compute-publish-diff.js", import.meta.url).pathname;
|
|
2358
|
+
|
|
2359
|
+
const acknowledged = [];
|
|
2360
|
+
const refused = [];
|
|
2361
|
+
const reissued = [];
|
|
2362
|
+
const timeouts = [];
|
|
2363
|
+
const skipped = [];
|
|
2364
|
+
const skip = (taskId, reason, detail) =>
|
|
2365
|
+
skipped.push({ task_id: taskId, ...(reason ? { reason } : {}), ...(detail ? { detail } : {}) });
|
|
2366
|
+
|
|
2367
|
+
const parked = db.prepare(`SELECT id AS task_id FROM tasks WHERE state = 'parked'`).all();
|
|
2591
2368
|
|
|
2592
2369
|
db.exec("BEGIN");
|
|
2593
2370
|
try {
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2371
|
+
for (const row of parked) {
|
|
2372
|
+
const taskId = row.task_id;
|
|
2373
|
+
const latest = latestPublishNote(db, taskId);
|
|
2374
|
+
if (!latest || !latest.message.includes("publish: edit-issued")) { skip(taskId, "not-issued"); continue; }
|
|
2375
|
+
const m = latest.message.match(/publish:\s*edit-issued\s+([0-9a-f]{40})\s+attempt=(\d+)/);
|
|
2376
|
+
if (!m) {
|
|
2377
|
+
skip(taskId, "edit-issued-unparseable",
|
|
2378
|
+
"the edit-issued note carries no commit hash + attempt=N — stays parked for human attention");
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2381
|
+
const commit = m[1];
|
|
2382
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2383
|
+
const project = task && db.prepare("SELECT id, deploy_slug, repo_path FROM projects WHERE id = ?").get(task.project);
|
|
2384
|
+
const slug = project && project.deploy_slug;
|
|
2385
|
+
if (!slug) { skip(taskId, "no-deploy-slug"); continue; }
|
|
2386
|
+
const ledger = readTaskLedgerEntries(crewHome, slug, taskId);
|
|
2387
|
+
if (!ledger.ok) { skip(taskId, "ledger-unreadable", ledger.reason); continue; }
|
|
2388
|
+
// The issuance record: issuer-stamped submitted entries with
|
|
2389
|
+
// derivation-verified versions. A version that fails the derivation is
|
|
2390
|
+
// not this machine's — the whole attempt is unusable, fail closed.
|
|
2391
|
+
const submittedAll = findSubmittedEntries(ledger.entries, commit);
|
|
2392
|
+
if (submittedAll.length === 0) {
|
|
2393
|
+
skip(taskId, "no-issuance-record",
|
|
2394
|
+
"no issuer-stamped submitted ledger entry for this commit — failing closed; stays parked for human attention");
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
// The ack version is derived deterministically from (task, commit,
|
|
2398
|
+
// attempt) — §1.1. An entry whose version fails the derivation is a
|
|
2399
|
+
// phantom claim: it never binds (D2). A corrupt entry poisons only
|
|
2400
|
+
// itself — it is quarantined while valid entries still drive the
|
|
2401
|
+
// windows and the budget, so one bad ledger line can never park the
|
|
2402
|
+
// whole record before the budget is evaluated. Only
|
|
2403
|
+
// derivation-verified entries anchor the clocks below.
|
|
2404
|
+
const attempts = [];
|
|
2405
|
+
const validEntries = [];
|
|
2406
|
+
for (const e of submittedAll) {
|
|
2407
|
+
const pa = verifiedSubmittedAttempt(taskId, e);
|
|
2408
|
+
if (pa === null) continue;
|
|
2409
|
+
if (!attempts.includes(pa)) attempts.push(pa);
|
|
2410
|
+
validEntries.push(e);
|
|
2411
|
+
}
|
|
2412
|
+
if (attempts.length === 0) {
|
|
2413
|
+
skip(taskId, "version-mismatch",
|
|
2414
|
+
"no submitted entry's version passes the deterministic derivation — the issuance record is unusable; stays parked for human attention");
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
attempts.sort((a, b) => a - b);
|
|
2418
|
+
const expectedVersion = (a) => derivePublishVersion(commit, a);
|
|
2419
|
+
|
|
2420
|
+
// --- acknowledgement evidence (§1.3): the version receipt on disk, or
|
|
2421
|
+
// a version-echoing builder report. ANY issued version for this
|
|
2422
|
+
// task+commit counts — including a late earlier attempt's. The
|
|
2423
|
+
// receipt is scoped per task (`.crew-publish-version-<task_id>`):
|
|
2424
|
+
// two tasks of the same project can sit in `edit-issued`
|
|
2425
|
+
// concurrently (per-project simultaneity defaults to 2), and a
|
|
2426
|
+
// shared per-slug receipt would let each issuance overwrite the
|
|
2427
|
+
// other's acknowledgement — receipt ping-pong with no false ack.
|
|
2428
|
+
let diskVersion = null;
|
|
2610
2429
|
try {
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
//
|
|
2621
|
-
//
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
//
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2430
|
+
diskVersion = readFileSync(join(spacesRoot, slug, ".crew-publish-version-" + taskId), "utf8").trim() || null;
|
|
2431
|
+
if (diskVersion && !isPublishVersion(diskVersion)) diskVersion = null;
|
|
2432
|
+
} catch { diskVersion = null; }
|
|
2433
|
+
const reports = db.prepare(
|
|
2434
|
+
`SELECT version, outcome FROM builder_reports WHERE task_id = ?`
|
|
2435
|
+
).all(taskId);
|
|
2436
|
+
// (2026-09-20 REVIEW) Disk is the SOLE positive evidence. The
|
|
2437
|
+
// builder-report "acknowledged" path was circular: the tick worker
|
|
2438
|
+
// knows the version (it was in the scan entry), so report_text
|
|
2439
|
+
// containing the version proves version knowledge, not artifact/platform
|
|
2440
|
+
// origin. A worker can fabricate an acknowledged report and cause
|
|
2441
|
+
// false provenance stamping. Builder reports are retained for
|
|
2442
|
+
// explicit REFUSALS only (negative evidence).
|
|
2443
|
+
const ackedAttempt = attempts.find((a) => diskVersion === expectedVersion(a));
|
|
2444
|
+
if (ackedAttempt !== undefined) {
|
|
2445
|
+
recordVersionAck(db, ctx, {
|
|
2446
|
+
taskId, projectId: project.id, commit, version: expectedVersion(ackedAttempt), via: "disk",
|
|
2447
|
+
});
|
|
2448
|
+
acknowledged.push({ task_id: taskId, commit, attempt: ackedAttempt, via: "disk", crew_release: releaseName });
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
// Explicit refusal naming an issued version: terminal, no re-issue.
|
|
2452
|
+
const refusedAttempt = attempts.find((a) =>
|
|
2453
|
+
reports.some((r) => r.outcome === "refused" && r.version === expectedVersion(a)));
|
|
2454
|
+
if (refusedAttempt !== undefined) {
|
|
2455
|
+
writeGuardedPublishNote(db, taskId,
|
|
2456
|
+
`publish: publish-refused ${commit} attempt=${refusedAttempt} — the builder's report carried the ` +
|
|
2457
|
+
`attempt's exact version with an explicit refusal. No re-issue; parked for human attention.`,
|
|
2458
|
+
new Date(nowMs).toISOString());
|
|
2459
|
+
refused.push({ task_id: taskId, commit, attempt: refusedAttempt });
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
// The acknowledgement window runs from the LATEST issuance of this
|
|
2463
|
+
// commit — a 30-minute window PER ATTEMPT (0.14.6 §1.3, design §3, as
|
|
2464
|
+
// re-set 2026-09-20). Anchoring at the earliest issuance would give
|
|
2465
|
+
// attempts 2+ no window at all: they are always issued after attempt
|
|
2466
|
+
// 1's window expired, so the scan would re-issue or time out
|
|
2467
|
+
// immediately after they were issued. Late acknowledgements for any
|
|
2468
|
+
// previously issued version are still accepted above; only the
|
|
2469
|
+
// re-issue/timeout clock follows the latest attempt.
|
|
2470
|
+
const latestIssuance = validEntries[validEntries.length - 1];
|
|
2471
|
+
const issuedAtMs = Date.parse(latestIssuance.issued_at || latestIssuance.ts);
|
|
2472
|
+
if (issuedAtMs > 0 && nowMs < issuedAtMs + ACK_WINDOW_MS) { skip(taskId, "window-open"); continue; }
|
|
2473
|
+
// An unparseable latest issuance is an UNKNOWN window — never call
|
|
2474
|
+
// it "window-open". The budget check below still runs, so a corrupt
|
|
2475
|
+
// latest timestamp cannot defer the terminal timeout forever; only
|
|
2476
|
+
// the re-issue is withheld (re-issuing into an unknown window would
|
|
2477
|
+
// risk a duplicate issuance the budget cannot yet justify).
|
|
2478
|
+
// Window expired. Budget remains → re-issue with a FRESH version;
|
|
2479
|
+
// budget exhausted → terminal version-timeout. The budget is measured
|
|
2480
|
+
// from the FIRST issuance of this commit: the whole publish effort
|
|
2481
|
+
// gets one 2-hour budget, however many 30-minute attempts fit inside
|
|
2482
|
+
// it. A fixed attempt cap would false-timeout a slow-but-healthy
|
|
2483
|
+
// artifact; a pure window with no budget would retry forever.
|
|
2484
|
+
const firstIssuance = validEntries[0];
|
|
2485
|
+
const firstIssuedAtMs = Date.parse(firstIssuance.issued_at || firstIssuance.ts);
|
|
2486
|
+
if (!(firstIssuedAtMs > 0)) {
|
|
2487
|
+
skip(taskId, "first-issuance-unparseable",
|
|
2488
|
+
"the first submitted entry's issued_at is unparseable — failing closed; stays parked for human attention");
|
|
2489
|
+
continue;
|
|
2490
|
+
}
|
|
2491
|
+
const maxAttempt = Math.max(...attempts);
|
|
2492
|
+
if (nowMs - firstIssuedAtMs >= TOTAL_BUDGET_MS) {
|
|
2493
|
+
writeGuardedPublishNote(db, taskId,
|
|
2494
|
+
`publish: version-timeout ${commit} — the 2-hour acknowledgement budget is exhausted ` +
|
|
2495
|
+
`(${maxAttempt} attempts, no version acknowledgement; unobserved within budget — the edit may ` +
|
|
2496
|
+
`still have landed; the outcome is unknown). Parked for human attention.`,
|
|
2497
|
+
new Date(nowMs).toISOString());
|
|
2498
|
+
timeouts.push({ task_id: taskId, commit, attempts: maxAttempt });
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
if (!(issuedAtMs > 0)) {
|
|
2502
|
+
skip(taskId, "latest-issuance-unparseable",
|
|
2503
|
+
"the latest submitted entry's issued_at is unparseable — the acknowledgement window state is " +
|
|
2504
|
+
"unknown and the total budget is not exhausted; stays parked for human attention");
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
const newAttempt = maxAttempt + 1;
|
|
2508
|
+
// Idempotency: a previous scan may already have prepared this
|
|
2509
|
+
// attempt. The guard is keyed on the SAME anchor the intent scan
|
|
2510
|
+
// claims on — the `publish: publish-requested <commit> attempt=N`
|
|
2511
|
+
// note — not the ledger line. The ledger append and the note write
|
|
2512
|
+
// are not atomic: a crash between them (or a rolled-back COMMIT)
|
|
2513
|
+
// leaves an orphan ledger line with no note, and a line-keyed guard
|
|
2514
|
+
// would skip it as "already prepared" until the budget forced the
|
|
2515
|
+
// terminal timeout. Keyed on the note, a torn prepare self-heals:
|
|
2516
|
+
// the orphan line is content-identical (deterministic version) and the
|
|
2517
|
+
// intent scan claims the re-appended line exactly once. Do NOT
|
|
2518
|
+
// reverse the write order: a note-without-line tear would park
|
|
2519
|
+
// permanently with no budget-expiry resolution.
|
|
2520
|
+
const prepareNote = db.prepare(
|
|
2521
|
+
`SELECT id FROM events WHERE task_id = ? AND type = 'note'
|
|
2522
|
+
AND message LIKE ? LIMIT 1`
|
|
2523
|
+
).get(taskId, `publish: publish-requested ${commit} attempt=${newAttempt} %`);
|
|
2524
|
+
if (prepareNote) { skip(taskId, "reissue-already-prepared"); continue; }
|
|
2525
|
+
// The re-issue diff is recomputed against CURRENT stamped provenance
|
|
2526
|
+
// (or the empty tree): the ack scan stages the attempt the way the
|
|
2527
|
+
// workflow staged attempt 1. A query error is not "no provenance" —
|
|
2528
|
+
// fail closed.
|
|
2529
|
+
let base;
|
|
2530
|
+
try {
|
|
2531
|
+
const prov = db.prepare("SELECT provenance_source_commit FROM projects WHERE id = ?").get(project.id);
|
|
2532
|
+
base = (prov && /^[0-9a-f]{40}$/.test(prov.provenance_source_commit))
|
|
2533
|
+
? prov.provenance_source_commit : EMPTY_TREE;
|
|
2534
|
+
} catch (e) {
|
|
2535
|
+
skip(taskId, "provenance-query-failed",
|
|
2536
|
+
`provenance lookup failed (${e.message.slice(0, 200)}); failing closed — the next scan retries`);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
const repoPath = project.repo_path;
|
|
2540
|
+
if (!repoPath) { skip(taskId, "no-repo-path"); continue; }
|
|
2541
|
+
const newVersion = derivePublishVersion(commit, newAttempt);
|
|
2542
|
+
const diffPath = join(crewHome, ".publish-diffs", `${taskId}-a${newAttempt}.diff`);
|
|
2543
|
+
let diffJson = null;
|
|
2544
|
+
try {
|
|
2545
|
+
const out = execFileSync("node", [computeDiffScript,
|
|
2546
|
+
"--repo-path", repoPath, "--base", base, "--commit", commit,
|
|
2547
|
+
"--task-id", taskId, "--attempt", String(newAttempt), "--out", diffPath],
|
|
2548
|
+
{ encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
|
|
2549
|
+
diffJson = JSON.parse(String(out).trim());
|
|
2550
|
+
} catch (e) {
|
|
2551
|
+
skip(taskId, "reissue-diff-failed", `diff recomputation failed: ${String((e && e.message) || e).slice(0, 200)}`);
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
if (!diffJson || diffJson.ok !== true || diffJson.version !== newVersion ||
|
|
2555
|
+
!/^[0-9a-f]{64}$/.test(diffJson.sha256 || "")) {
|
|
2556
|
+
skip(taskId, "reissue-diff-unusable",
|
|
2557
|
+
"the recomputed diff is unusable (version mismatch or bad checksum) — stays parked for human attention");
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
// No backoff: the reissue is immediately claimable by the next intent
|
|
2561
|
+
// scan. The window expiry is the only spacing — a slow artifact gets
|
|
2562
|
+
// its full window per attempt, and a dropped one is retried on the
|
|
2563
|
+
// next tick, not after an arbitrary extra wait.
|
|
2564
|
+
const ts = new Date(nowMs).toISOString();
|
|
2565
|
+
const intentLine = {
|
|
2566
|
+
ts, task_id: taskId, workflow: "ack-reissue", slug,
|
|
2567
|
+
commit, attempt: null, publish_attempt: newAttempt, version: newVersion,
|
|
2568
|
+
agent_id: null, applied_report: null,
|
|
2569
|
+
issuer: "tick-worker",
|
|
2570
|
+
diff_path: diffPath, diff_sha256: diffJson.sha256,
|
|
2571
|
+
files: Array.isArray(diffJson.files) ? diffJson.files : null,
|
|
2572
|
+
base, outcome: "publish-intent",
|
|
2573
|
+
detail: `ack scan re-issue: attempt ${maxAttempt}'s 30-minute window expired with no version acknowledgement; ` +
|
|
2574
|
+
`fresh version staged, immediately claimable (no backoff)`,
|
|
2575
|
+
};
|
|
2576
|
+
try {
|
|
2577
|
+
appendFileSync(ledger.ledgerPath, JSON.stringify(intentLine) + "\n");
|
|
2578
|
+
} catch (e) {
|
|
2579
|
+
skip(taskId, "reissue-ledger-failed", `cannot append publish ledger: ${e.message}`);
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
writeGuardedPublishNote(db, taskId,
|
|
2583
|
+
`publish: publish-requested ${commit} attempt=${newAttempt} — ack scan re-issue attempt ${newAttempt}: ` +
|
|
2584
|
+
`no version acknowledgement within the 30-minute window; fresh version staged, immediately claimable ` +
|
|
2585
|
+
`(2-hour total budget from first issuance).`,
|
|
2586
|
+
ts);
|
|
2587
|
+
reissued.push({ task_id: taskId, commit, attempt: newAttempt });
|
|
2647
2588
|
}
|
|
2648
2589
|
db.exec("COMMIT");
|
|
2649
|
-
return { recorded: true, decision: verdict, task_id: taskId, note };
|
|
2650
2590
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2591
|
+
|
|
2592
|
+
return { acknowledged, refused, reissued, timeouts, skipped };
|
|
2651
2593
|
};
|
|
2652
2594
|
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
// than provably-dropped. This command routes that decision — code-owned, so
|
|
2657
|
-
// the tick never free-forms the state machine.
|
|
2595
|
+
// Record the artifact platform's report text for an issued edit (0.14.6
|
|
2596
|
+
// §1.4). The tick worker calls this after each artifact_edit call with the
|
|
2597
|
+
// result/refusal text verbatim.
|
|
2658
2598
|
//
|
|
2659
|
-
// Args: { task_id,
|
|
2660
|
-
//
|
|
2661
|
-
// CAS: the latest publish: note must still be "publish: dropped" — the merge
|
|
2662
|
-
// lock is the mutual exclusion; the CAS is the backstop against a tick that
|
|
2663
|
-
// died after another writer moved the state.
|
|
2599
|
+
// Args: { task_id, version, outcome: "acknowledged"|"refused",
|
|
2600
|
+
// report_text }.
|
|
2664
2601
|
//
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
//
|
|
2670
|
-
//
|
|
2671
|
-
//
|
|
2672
|
-
//
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
const
|
|
2602
|
+
// Mechanical guards (the command, not prose, decides):
|
|
2603
|
+
// - report_text must contain the EXACT version string, else rejected;
|
|
2604
|
+
// - the version must match an issued attempt for this task (derived, never
|
|
2605
|
+
// trusted from prose), else rejected.
|
|
2606
|
+
// A refused report naming an issued version terminal-parks the task at the
|
|
2607
|
+
// next ack scan (`publish: publish-refused`). (2026-09-20 REVIEW) The
|
|
2608
|
+
// "acknowledged" outcome is rejected: it was circular (the worker knows
|
|
2609
|
+
// the version, so the report proves nothing), and disk is the sole positive
|
|
2610
|
+
// evidence. The rows persist as evidence.
|
|
2611
|
+
commands["record-builder-report"] = (db, args, ctx) => {
|
|
2612
|
+
const taskId = args.task_id;
|
|
2613
|
+
const version = args.version;
|
|
2614
|
+
const outcome = args.outcome;
|
|
2615
|
+
const reportText = args.report_text;
|
|
2677
2616
|
if (!taskId) throw usageError("task_id is required.");
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2617
|
+
if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
|
|
2618
|
+
// (2026-09-20 REVIEW) "acknowledged" is rejected — the positive
|
|
2619
|
+
// builder-report path was circular. Disk is the sole positive evidence.
|
|
2620
|
+
if (outcome !== "refused") throw usageError(`unknown outcome: ${outcome} (only "refused" is accepted).`);
|
|
2621
|
+
if (typeof reportText !== "string" || !reportText.includes(version)) {
|
|
2622
|
+
throw usageError("report_text must contain the exact version string — the report is mechanically rejected otherwise.");
|
|
2681
2623
|
}
|
|
2682
|
-
const
|
|
2683
|
-
if (!
|
|
2684
|
-
|
|
2624
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2625
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2626
|
+
if (task.state !== "parked") {
|
|
2627
|
+
throw usageError(`task is not parked (state=${task.state}) — builder reports attach to parked publish tasks.`);
|
|
2685
2628
|
}
|
|
2686
|
-
|
|
2687
|
-
const
|
|
2688
|
-
if (!
|
|
2689
|
-
const
|
|
2690
|
-
if (!
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2629
|
+
const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2630
|
+
const slug = project && project.deploy_slug;
|
|
2631
|
+
if (!slug) throw usageError("project has no deploy_slug.");
|
|
2632
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
2633
|
+
if (!ledger.ok) throw usageError(ledger.reason);
|
|
2634
|
+
let matched = null;
|
|
2635
|
+
for (const e of ledger.entries) {
|
|
2636
|
+
if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
|
|
2637
|
+
if (verifiedSubmittedAttempt(taskId, e) === null) continue;
|
|
2638
|
+
if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
|
|
2639
|
+
}
|
|
2640
|
+
if (!matched) {
|
|
2641
|
+
throw usageError("version does not match any issued attempt for this task — the report is rejected.");
|
|
2695
2642
|
}
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
const
|
|
2719
|
-
const
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2643
|
+
db.prepare(
|
|
2644
|
+
`INSERT INTO builder_reports (id, task_id, version, outcome, report_text, created_at)
|
|
2645
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2646
|
+
).run(uuid(), taskId, version, outcome, reportText.slice(0, 20000), now());
|
|
2647
|
+
return {
|
|
2648
|
+
ok: true, task_id: taskId, version, outcome,
|
|
2649
|
+
commit: matched.commit, publish_attempt: matched.publish_attempt,
|
|
2650
|
+
};
|
|
2651
|
+
};
|
|
2652
|
+
|
|
2653
|
+
// Record a positive version acknowledgement found off the ack scan's path
|
|
2654
|
+
// (0.14.6 §1.3): the re-claiming tick read the dead attempt's version receipt
|
|
2655
|
+
// from the artifact's disk. The version must name an issued attempt for this
|
|
2656
|
+
// task (derived, never trusted) — ANY issued version for the task counts,
|
|
2657
|
+
// including a late earlier attempt's. (2026-09-20 REVIEW) The reclaim path
|
|
2658
|
+
// also accepts the exact disk version for the currently claimed intent when
|
|
2659
|
+
// no submitted entry exists (dead tick issued but died before recording).
|
|
2660
|
+
// Stamps provenance, writes the terminal `publish: version-acknowledged`
|
|
2661
|
+
// note, re-queues the task.
|
|
2662
|
+
//
|
|
2663
|
+
// Args: { task_id, version, via: "disk" }.
|
|
2664
|
+
commands["record-version-ack"] = (db, args, ctx) => {
|
|
2665
|
+
const taskId = args.task_id;
|
|
2666
|
+
const version = args.version;
|
|
2667
|
+
const via = args.via;
|
|
2668
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2669
|
+
if (!isPublishVersion(version)) throw usageError("version must be the per-attempt publish version (<commit>:<attempt>).");
|
|
2670
|
+
if (via !== "disk") throw usageError(`unknown via: ${via} (only "disk" is accepted).`);
|
|
2671
|
+
const task = db.prepare("SELECT id, state, project FROM tasks WHERE id = ?").get(taskId);
|
|
2672
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2673
|
+
if (task.state !== "parked") {
|
|
2674
|
+
throw usageError(`task is not parked (state=${task.state}) — version acks attach to parked publish tasks.`);
|
|
2675
|
+
}
|
|
2676
|
+
const project = db.prepare("SELECT id, deploy_slug FROM projects WHERE id = ?").get(task.project);
|
|
2677
|
+
const slug = project && project.deploy_slug;
|
|
2678
|
+
if (!slug) throw usageError("project has no deploy_slug.");
|
|
2679
|
+
const ledger = readTaskLedgerEntries(ctx.crewHome, slug, taskId);
|
|
2680
|
+
if (!ledger.ok) throw usageError(ledger.reason);
|
|
2681
|
+
let matched = null;
|
|
2682
|
+
for (const e of ledger.entries) {
|
|
2683
|
+
if (e.outcome !== "submitted" || e.issuer !== "tick-worker") continue;
|
|
2684
|
+
if (verifiedSubmittedAttempt(taskId, e) === null) continue;
|
|
2685
|
+
if (derivePublishVersion(e.commit, e.publish_attempt) === version) { matched = e; break; }
|
|
2686
|
+
}
|
|
2687
|
+
// (2026-09-20 REVIEW) Reclaim path: a tick that died after issuing but
|
|
2688
|
+
// before recording has no submitted entry. The code-written active claim
|
|
2689
|
+
// (publish-intent-claimed note) + the exact on-disk derived version for
|
|
2690
|
+
// the claimed intent's commit+attempt proves the dead issuance occurred.
|
|
2691
|
+
// Arbitrary/non-current/non-derived versions are still rejected.
|
|
2692
|
+
if (!matched) {
|
|
2693
|
+
const claimed = latestPublishNote(db, taskId);
|
|
2694
|
+
const intentEntry = [...ledger.entries].reverse().find((e) => e.outcome === "publish-intent") || null;
|
|
2695
|
+
if (claimed && claimed.message.includes("publish: publish-intent-claimed") &&
|
|
2696
|
+
intentEntry && /^[0-9a-f]{40}$/.test(intentEntry.commit || "") &&
|
|
2697
|
+
Number.isInteger(intentEntry.publish_attempt) && intentEntry.publish_attempt >= 1 &&
|
|
2698
|
+
derivePublishVersion(intentEntry.commit, intentEntry.publish_attempt) === version &&
|
|
2699
|
+
via === "disk") {
|
|
2700
|
+
matched = {
|
|
2701
|
+
commit: intentEntry.commit,
|
|
2702
|
+
publish_attempt: intentEntry.publish_attempt,
|
|
2703
|
+
// No submitted entry — the reclaim synthesizes the ack from the
|
|
2704
|
+
// claimed intent. The issued_at is unknown; the ack is timeless.
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
if (!matched) {
|
|
2709
|
+
throw usageError("version does not match any issued attempt for this task — the ack is rejected.");
|
|
2710
|
+
}
|
|
2711
|
+
db.exec("BEGIN");
|
|
2723
2712
|
try {
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2713
|
+
const res = recordVersionAck(db, ctx, {
|
|
2714
|
+
taskId, projectId: project.id, commit: matched.commit, version, via,
|
|
2715
|
+
});
|
|
2716
|
+
db.exec("COMMIT");
|
|
2717
|
+
return res;
|
|
2718
|
+
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
// The closed publish-note writer's prose entry point (0.14.6 §1.5): the
|
|
2722
|
+
// ONLY way for tick prose to write a state-carrying `publish: <verb>` note.
|
|
2723
|
+
// The verb is asserted against the closed registry in
|
|
2724
|
+
// lib/publish-note-vocabulary.js — prose cannot mint new `publish: <verb>`
|
|
2725
|
+
// states; an unregistered verb is a usage error, never a note.
|
|
2726
|
+
//
|
|
2727
|
+
// Args: { task_id, note } — the full note text.
|
|
2728
|
+
commands["record-publish-note"] = (db, args) => {
|
|
2729
|
+
const taskId = args.task_id;
|
|
2730
|
+
const note = String(args.note || "");
|
|
2731
|
+
if (!taskId) throw usageError("task_id is required.");
|
|
2732
|
+
if (!note) throw usageError("note is required.");
|
|
2733
|
+
const task = db.prepare("SELECT id, state FROM tasks WHERE id = ?").get(taskId);
|
|
2734
|
+
if (!task) throw notFound(`unknown task: ${taskId}`);
|
|
2735
|
+
if (task.state !== "parked") {
|
|
2736
|
+
throw usageError(`task is not parked (state=${task.state}) — publish notes attach to parked tasks.`);
|
|
2737
|
+
}
|
|
2738
|
+
let verb;
|
|
2739
|
+
try {
|
|
2740
|
+
verb = assertWritablePublishNote(note);
|
|
2741
|
+
} catch (e) {
|
|
2742
|
+
throw usageError(e.message);
|
|
2743
|
+
}
|
|
2744
|
+
writePublishNote(db, taskId, note);
|
|
2745
|
+
return { ok: true, task_id: taskId, verb };
|
|
2732
2746
|
};
|
|
2733
2747
|
|
|
2734
|
-
//
|
|
2735
|
-
//
|
|
2736
|
-
//
|
|
2737
|
-
//
|
|
2748
|
+
// STRUCTURED VERDICT RECORDS (room #26 blocker 33, 2026-09-21). Review
|
|
2749
|
+
// verdict grounds were destroyed by the 2000-char session-note truncation —
|
|
2750
|
+
// a park on a verified-correct implementation was unauditable. record-phase
|
|
2751
|
+
// optionally carries the full grounds (`args.verdict`); they are validated
|
|
2752
|
+
// at the API boundary and written to the verdicts table in the same
|
|
2753
|
+
// transaction as the session note + event, so the lossless record can never
|
|
2754
|
+
// be missing when the note exists. One verdict per recording session: a
|
|
2755
|
+
// re-recorded phase for the same session is the same evidence, so the first
|
|
2756
|
+
// write wins (ON CONFLICT DO NOTHING) and rows are never revised. A new
|
|
2757
|
+
// Review execution claims a new session, so rework rounds and dispatcher
|
|
2758
|
+
// retries each get their own row.
|
|
2759
|
+
const VERDICT_RECORD_STEPS = ["Review"];
|
|
2760
|
+
const VERDICT_VALUES = ["PASS", "FAIL", "INDETERMINATE"];
|
|
2761
|
+
const MAX_VERDICT_GROUNDS = 1000000;
|
|
2762
|
+
|
|
2763
|
+
function validateVerdictPayload(taskId, verdict, sessionRow) {
|
|
2764
|
+
if (typeof verdict !== "object" || verdict === null)
|
|
2765
|
+
throw usageError("verdict must be an object.");
|
|
2766
|
+
const { step, attempt, reviewer, verdict: value, grounds, review_basis } = verdict;
|
|
2767
|
+
if (!VERDICT_RECORD_STEPS.includes(step))
|
|
2768
|
+
throw usageError(`verdict step must be one of: ${VERDICT_RECORD_STEPS.join(", ")}.`);
|
|
2769
|
+
if (!Number.isInteger(attempt) || attempt < 0)
|
|
2770
|
+
throw usageError("verdict attempt must be a non-negative integer.");
|
|
2771
|
+
if (typeof reviewer !== "string" || reviewer.trim().length < 1 || reviewer.length > 200)
|
|
2772
|
+
throw usageError("verdict reviewer is required (1-200 chars).");
|
|
2773
|
+
if (!VERDICT_VALUES.includes(value))
|
|
2774
|
+
throw usageError(`verdict must be one of: ${VERDICT_VALUES.join(", ")}.`);
|
|
2775
|
+
if (typeof grounds !== "string" || grounds.length < 1 || grounds.length > MAX_VERDICT_GROUNDS)
|
|
2776
|
+
throw usageError(`verdict grounds are required (1-${MAX_VERDICT_GROUNDS} chars).`);
|
|
2777
|
+
// Room #26 blocker 34 (redesign): the structured review basis — what the
|
|
2778
|
+
// review actually examined. NULL when the review was not branch-classified
|
|
2779
|
+
// (docs.js path). Shape-pinned at the API boundary: a lowercase kind tag,
|
|
2780
|
+
// optionally qualified by a 40-hex merge sha (frozen-merge:<sha>).
|
|
2781
|
+
let reviewBasis = null;
|
|
2782
|
+
if (review_basis !== undefined && review_basis !== null) {
|
|
2783
|
+
// Exact closed set (blocker 34 redesign, 2026-09-21): the workflows emit
|
|
2784
|
+
// only branch-diff, mechanical-fail, runtime-state-none, or
|
|
2785
|
+
// frozen-merge:<40-hex-sha>. A broad regex would accept fabricated kinds.
|
|
2786
|
+
if (typeof review_basis !== "string" ||
|
|
2787
|
+
!/^(branch-diff|mechanical-fail|runtime-state-none|frozen-merge:[0-9a-f]{40})$/.test(review_basis))
|
|
2788
|
+
throw usageError("verdict review_basis must be null or one of 'branch-diff', 'mechanical-fail', 'runtime-state-none', 'frozen-merge:<40-hex-sha>'.");
|
|
2789
|
+
reviewBasis = review_basis;
|
|
2790
|
+
}
|
|
2791
|
+
return {
|
|
2792
|
+
id: uuid(),
|
|
2793
|
+
task_id: taskId,
|
|
2794
|
+
step,
|
|
2795
|
+
attempt,
|
|
2796
|
+
reviewer: reviewer.trim(),
|
|
2797
|
+
verdict: value,
|
|
2798
|
+
grounds,
|
|
2799
|
+
review_basis: reviewBasis,
|
|
2800
|
+
// summary is derived from the session note actually recorded — one
|
|
2801
|
+
// source of truth, never a second caller-supplied truncation.
|
|
2802
|
+
summary: sessionRow.notes,
|
|
2803
|
+
session_id: sessionRow.id,
|
|
2804
|
+
created_at: now(),
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2738
2808
|
commands["record-phase"] = (db, args) => {
|
|
2739
2809
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
2740
2810
|
requireTask(db, args.task_id);
|
|
@@ -2770,6 +2840,13 @@ commands["record-phase"] = (db, args) => {
|
|
|
2770
2840
|
id: uuid(), type: event.type, task_id: args.task_id,
|
|
2771
2841
|
identity: event.identity ?? sessionRow.identity, message, timestamp,
|
|
2772
2842
|
};
|
|
2843
|
+
// Blocker 33: validate the optional structured verdict BEFORE the
|
|
2844
|
+
// transaction — a malformed payload fails the whole phase record (fail
|
|
2845
|
+
// closed), never a silently dropped verdict.
|
|
2846
|
+
const verdictRow = args.verdict == null
|
|
2847
|
+
? null
|
|
2848
|
+
: validateVerdictPayload(args.task_id, args.verdict, sessionRow);
|
|
2849
|
+
let recordedVerdict = null;
|
|
2773
2850
|
db.exec("BEGIN");
|
|
2774
2851
|
try {
|
|
2775
2852
|
db.prepare(
|
|
@@ -2786,9 +2863,39 @@ commands["record-phase"] = (db, args) => {
|
|
|
2786
2863
|
db.prepare(
|
|
2787
2864
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
2788
2865
|
VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(eventRow);
|
|
2866
|
+
// Blocker 33: the verdict lands in the same transaction as the session
|
|
2867
|
+
// note + event — the lossless record can never be missing when the note
|
|
2868
|
+
// exists. Idempotency is session-keyed: a re-recorded phase for the
|
|
2869
|
+
// same session is the same evidence (first write wins, never revised);
|
|
2870
|
+
// a new Review execution claims a new session and gets its own row.
|
|
2871
|
+
if (verdictRow) {
|
|
2872
|
+
const verdictInsert = db.prepare(
|
|
2873
|
+
`INSERT INTO verdicts (id, task_id, step, attempt, reviewer, verdict,
|
|
2874
|
+
grounds, review_basis, summary, session_id, created_at)
|
|
2875
|
+
VALUES (@id, @task_id, @step, @attempt, @reviewer, @verdict,
|
|
2876
|
+
@grounds, @review_basis, @summary, @session_id, @created_at)
|
|
2877
|
+
ON CONFLICT(session_id) DO NOTHING`).run(verdictRow);
|
|
2878
|
+
recordedVerdict = verdictInsert.changes === 1
|
|
2879
|
+
? verdictRow
|
|
2880
|
+
: db.prepare(
|
|
2881
|
+
`SELECT * FROM verdicts WHERE session_id = ?`).get(verdictRow.session_id);
|
|
2882
|
+
}
|
|
2789
2883
|
db.exec("COMMIT");
|
|
2790
2884
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2791
|
-
return { session: mapSession(sessionRow), event: mapEvent(eventRow)
|
|
2885
|
+
return { session: mapSession(sessionRow), event: mapEvent(eventRow),
|
|
2886
|
+
verdict: mapVerdict(recordedVerdict) };
|
|
2887
|
+
};
|
|
2888
|
+
|
|
2889
|
+
// Blocker 33 read surface: all structured verdict records for a task,
|
|
2890
|
+
// ordered by recording time (rework rounds and retries accumulate as
|
|
2891
|
+
// separate rows, never overwrite).
|
|
2892
|
+
commands["get-verdicts"] = (db, args) => {
|
|
2893
|
+
if (!args.task_id) throw usageError("task_id is required.");
|
|
2894
|
+
requireTask(db, args.task_id);
|
|
2895
|
+
const rows = db.prepare(
|
|
2896
|
+
`SELECT * FROM verdicts WHERE task_id = ? ORDER BY created_at ASC`
|
|
2897
|
+
).all(args.task_id);
|
|
2898
|
+
return { verdicts: rows.map(mapVerdict) };
|
|
2792
2899
|
};
|
|
2793
2900
|
|
|
2794
2901
|
// Initial provenance stamp (2026-09-16, clean-room task ada95b71): the first
|
|
@@ -2823,7 +2930,7 @@ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType
|
|
|
2823
2930
|
// Symlink-only resolution: the stamp records the crew home's ACTIVE
|
|
2824
2931
|
// release — it must not depend on which copy of this file is running.
|
|
2825
2932
|
// The strict self-path cross-check lives in resolveActiveRelease, used by
|
|
2826
|
-
//
|
|
2933
|
+
// the ack/intent scans; requiring it here silently skipped the stamp
|
|
2827
2934
|
// for every setup path that invokes crew-api.js outside the release tree
|
|
2828
2935
|
// (room #15: create-project through the npm package's own lib/).
|
|
2829
2936
|
try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
|
|
@@ -3069,7 +3176,7 @@ commands["update-config"] = (db, args) => {
|
|
|
3069
3176
|
// never touches another's row.
|
|
3070
3177
|
function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
|
|
3071
3178
|
// task_id invariant (2026-09-19 REVIEW): task_id is set by every REAL
|
|
3072
|
-
// publish (
|
|
3179
|
+
// publish (the version ack scan and the workflow provenance-refresh paths)
|
|
3073
3180
|
// and is null for initial stamps (tryStampInitialProvenance) and for
|
|
3074
3181
|
// publish-base advances (lib/advance-publish-base.js). So
|
|
3075
3182
|
// provenance.task_id != null ⟺ a real publish has stamped this project.
|
|
@@ -3164,12 +3271,12 @@ commands["migrate"] = (db, args) => {
|
|
|
3164
3271
|
|
|
3165
3272
|
// import-json: one-time import from a dashboard exportCrewState JSON payload.
|
|
3166
3273
|
// Used for the live migration (the artifact's app.db is never opened directly).
|
|
3167
|
-
// Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...] } }
|
|
3274
|
+
// Args: { data: { projects: [...], tasks: [...], agent_sessions: [...], events: [...], config: [...], poll_state: [...], builder_reports: [...] } }
|
|
3168
3275
|
// Or pipe the JSON via stdin.
|
|
3169
3276
|
commands["import-json"] = (db, args) => {
|
|
3170
3277
|
const data = args.data;
|
|
3171
3278
|
if (!data || typeof data !== "object") throw usageError("data must be the exportCrewState JSON object.");
|
|
3172
|
-
const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events"];
|
|
3279
|
+
const tables = ["projects", "tasks", "poll_state", "config", "agent_sessions", "events", "builder_reports"];
|
|
3173
3280
|
const counts = {};
|
|
3174
3281
|
db.exec("BEGIN");
|
|
3175
3282
|
try {
|