muse-crew 0.14.6 → 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 +45 -5
- package/docs/decisions/AGENTS.md +2 -0
- package/docs/decisions/publish-path.md +56 -8
- package/docs/decisions/qa-reproduce.md +30 -0
- package/lib/AGENTS.md +6 -5
- package/lib/crew-api.js +403 -7
- package/lib/merge-lock.sh +153 -41
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +50 -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/cron-body-template.md +6 -6
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +434 -106
- package/workflows/chore.js +251 -108
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +439 -111
- package/workflows/upgrade.js +13 -1
package/lib/crew-api.js
CHANGED
|
@@ -338,6 +338,74 @@ function openDb(crewHome) {
|
|
|
338
338
|
}
|
|
339
339
|
// Legacy global-provenance backfill (watermarked, runs at most once).
|
|
340
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
|
+
}
|
|
341
409
|
}
|
|
342
410
|
return db;
|
|
343
411
|
}
|
|
@@ -397,6 +465,9 @@ function mapTask(row, taskStates = new Map()) {
|
|
|
397
465
|
deps,
|
|
398
466
|
filed_by: row.filed_by,
|
|
399
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,
|
|
400
471
|
blocked: deps.some((id) => taskStates.get(id) !== "done"),
|
|
401
472
|
created_at: row.created_at,
|
|
402
473
|
updated_at: row.updated_at,
|
|
@@ -434,6 +505,135 @@ function mapEvent(row) {
|
|
|
434
505
|
};
|
|
435
506
|
}
|
|
436
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
|
+
|
|
437
637
|
// Retry counting, ported verbatim from the dashboard's retry.ts.
|
|
438
638
|
function countConsecutiveFailures(sessions, retryResetAt) {
|
|
439
639
|
const latest = sessions[0];
|
|
@@ -566,6 +766,23 @@ commands["get-dispatch-state"] = (db) => {
|
|
|
566
766
|
WHERE status = 'running' AND datetime(COALESCE(last_heartbeat, started_at)) < datetime('now', '-1 hour')`
|
|
567
767
|
).run();
|
|
568
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
|
+
|
|
569
786
|
const taskRows = db.prepare("SELECT * FROM tasks").all();
|
|
570
787
|
const projectRows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
|
|
571
788
|
const configRows = db.prepare("SELECT key, value FROM config WHERE key <> 'simultaneity'").all();
|
|
@@ -770,6 +987,13 @@ commands["update-task"] = (db, args, ctx) => {
|
|
|
770
987
|
const timestamp = now();
|
|
771
988
|
const patch = {};
|
|
772
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
|
+
}
|
|
773
997
|
if (args.title !== undefined) {
|
|
774
998
|
const t = args.title.trim();
|
|
775
999
|
if (t.length < 1 || t.length > 200) throw usageError("title must be 1-200 chars.");
|
|
@@ -802,6 +1026,21 @@ commands["update-task"] = (db, args, ctx) => {
|
|
|
802
1026
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
803
1027
|
VALUES (?, ?, ?, NULL, ?, ?)`
|
|
804
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
|
+
}
|
|
805
1044
|
}
|
|
806
1045
|
db.exec("COMMIT");
|
|
807
1046
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
@@ -971,6 +1210,14 @@ commands["list-reservations"] = (db) => {
|
|
|
971
1210
|
commands["record-run-start"] = (db, args) => {
|
|
972
1211
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
973
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
|
+
}
|
|
974
1221
|
// The workflow doesn't know its platform run_id; the telemetry run_id is
|
|
975
1222
|
// minted here (SQLite-side) so workflow JS never touches randomness.
|
|
976
1223
|
const runId = args.run_id || uuid();
|
|
@@ -1021,9 +1268,12 @@ commands["record-run-end"] = (db, args) => {
|
|
|
1021
1268
|
if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
|
|
1022
1269
|
throw usageError("status must be completed, failed, parked, or timed_out.");
|
|
1023
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).
|
|
1024
1274
|
db.prepare(
|
|
1025
1275
|
`UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
|
|
1026
|
-
WHERE run_id =
|
|
1276
|
+
WHERE run_id = ? AND status = 'running'`
|
|
1027
1277
|
).run(status, args.run_id);
|
|
1028
1278
|
return { ok: true, run_id: args.run_id, status };
|
|
1029
1279
|
};
|
|
@@ -1176,8 +1426,15 @@ commands["retry-platform-failure"] = (db, args) => {
|
|
|
1176
1426
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1177
1427
|
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
1178
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);
|
|
1179
1436
|
db.exec("COMMIT");
|
|
1180
|
-
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 };
|
|
1181
1438
|
} catch (e) {
|
|
1182
1439
|
db.exec("ROLLBACK");
|
|
1183
1440
|
throw e;
|
|
@@ -1275,12 +1532,21 @@ commands["park-task"] = (db, args) => {
|
|
|
1275
1532
|
notes = notes || ' Parked: ' || ?
|
|
1276
1533
|
WHERE task_id = ? AND status = 'running'`
|
|
1277
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);
|
|
1278
1542
|
db.exec("COMMIT");
|
|
1279
1543
|
const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
|
|
1280
1544
|
return {
|
|
1281
1545
|
ok: true,
|
|
1282
1546
|
task: mapTask({ ...task, state: "parked", updated_at: timestamp }, taskStates),
|
|
1283
1547
|
settled_sessions: Number(settled.changes),
|
|
1548
|
+
closed_workflow_runs: closedRuns,
|
|
1549
|
+
cascade_parked: cascadeParked,
|
|
1284
1550
|
};
|
|
1285
1551
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1286
1552
|
};
|
|
@@ -1324,7 +1590,9 @@ commands["recover-task"] = (db, args, ctx) => {
|
|
|
1324
1590
|
const note = (args.updated_description ?? "").trim().slice(0, 5000);
|
|
1325
1591
|
db.exec("BEGIN");
|
|
1326
1592
|
try {
|
|
1327
|
-
|
|
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 = ?")
|
|
1328
1596
|
.run(targetPhase, newState, timestamp, task.id);
|
|
1329
1597
|
db.prepare(
|
|
1330
1598
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
@@ -1334,11 +1602,14 @@ commands["recover-task"] = (db, args, ctx) => {
|
|
|
1334
1602
|
(task.state !== newState ? ` (state ${task.state} -> ${newState})` : "") +
|
|
1335
1603
|
(note ? `. ${note}` : ""),
|
|
1336
1604
|
timestamp);
|
|
1605
|
+
if (task.state === "parked" && newState !== "parked") {
|
|
1606
|
+
releaseDepParks(db, task.id, timestamp);
|
|
1607
|
+
}
|
|
1337
1608
|
db.exec("COMMIT");
|
|
1338
1609
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
1339
1610
|
const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
|
|
1340
1611
|
return {
|
|
1341
|
-
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),
|
|
1342
1613
|
valid_phases: stepNames,
|
|
1343
1614
|
};
|
|
1344
1615
|
};
|
|
@@ -1921,12 +2192,36 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
|
1921
2192
|
if (!["accepted", "refused"].includes(editOutcome)) {
|
|
1922
2193
|
throw usageError(`unknown edit outcome: ${editOutcome}.`);
|
|
1923
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
|
+
}
|
|
1924
2215
|
|
|
1925
2216
|
db.exec("BEGIN");
|
|
1926
2217
|
try {
|
|
1927
2218
|
const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
|
|
1928
2219
|
if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
|
|
1929
2220
|
const { slug, ledgerPath, intentEntry, commit } = pre;
|
|
2221
|
+
if (reclaimed && receiptCheck === "match") {
|
|
2222
|
+
db.exec("ROLLBACK");
|
|
2223
|
+
return { recorded: false, reason: "receipt already matches the claimed version — the edit landed; acknowledge via record-version-ack, never re-issue" };
|
|
2224
|
+
}
|
|
1930
2225
|
const publishAttempt = intentEntry.publish_attempt;
|
|
1931
2226
|
const version = intentEntry.version;
|
|
1932
2227
|
if (!Number.isInteger(publishAttempt) || publishAttempt < 1 ||
|
|
@@ -1942,19 +2237,22 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
|
|
|
1942
2237
|
issuer: "tick-worker",
|
|
1943
2238
|
diff_sha256: intentEntry.diff_sha256 || null,
|
|
1944
2239
|
issued_at: editOutcome === "accepted" ? ts : null,
|
|
2240
|
+
receipt_check: reclaimed ? receiptCheck : null,
|
|
1945
2241
|
};
|
|
1946
2242
|
let note;
|
|
1947
2243
|
if (editOutcome === "refused") {
|
|
1948
2244
|
const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
|
|
1949
2245
|
ledgerLine.outcome = "rejected";
|
|
1950
|
-
ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}
|
|
2246
|
+
ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}` +
|
|
2247
|
+
(reclaimed ? ` (reclaimed claim; step-2 receipt check was '${receiptCheck}')` : "");
|
|
1951
2248
|
// Terminal: parked for human attention. "publish: publish-refused"
|
|
1952
2249
|
// is registered in lib/publish-note-vocabulary.js (the closed
|
|
1953
2250
|
// registry — the writer-side guard pattern from D7).
|
|
1954
2251
|
note = `publish: publish-refused ${commit} attempt=${publishAttempt} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
|
|
1955
2252
|
} else {
|
|
1956
2253
|
ledgerLine.outcome = "submitted";
|
|
1957
|
-
ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt"
|
|
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)` : "");
|
|
1958
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.`;
|
|
1959
2257
|
}
|
|
1960
2258
|
try {
|
|
@@ -2446,6 +2744,67 @@ commands["record-publish-note"] = (db, args) => {
|
|
|
2446
2744
|
writePublishNote(db, taskId, note);
|
|
2447
2745
|
return { ok: true, task_id: taskId, verb };
|
|
2448
2746
|
};
|
|
2747
|
+
|
|
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
|
+
|
|
2449
2808
|
commands["record-phase"] = (db, args) => {
|
|
2450
2809
|
if (!args.task_id) throw usageError("task_id is required.");
|
|
2451
2810
|
requireTask(db, args.task_id);
|
|
@@ -2481,6 +2840,13 @@ commands["record-phase"] = (db, args) => {
|
|
|
2481
2840
|
id: uuid(), type: event.type, task_id: args.task_id,
|
|
2482
2841
|
identity: event.identity ?? sessionRow.identity, message, timestamp,
|
|
2483
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;
|
|
2484
2850
|
db.exec("BEGIN");
|
|
2485
2851
|
try {
|
|
2486
2852
|
db.prepare(
|
|
@@ -2497,9 +2863,39 @@ commands["record-phase"] = (db, args) => {
|
|
|
2497
2863
|
db.prepare(
|
|
2498
2864
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
2499
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
|
+
}
|
|
2500
2883
|
db.exec("COMMIT");
|
|
2501
2884
|
} catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
2502
|
-
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) };
|
|
2503
2899
|
};
|
|
2504
2900
|
|
|
2505
2901
|
// Initial provenance stamp (2026-09-16, clean-room task ada95b71): the first
|