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/merge-lock.sh
CHANGED
|
@@ -19,6 +19,20 @@
|
|
|
19
19
|
# (or malformed) lease is broken with logging and re-acquired. Every op
|
|
20
20
|
# appends one line to $CREW_HOME/.merge-lock.log (the audit trail).
|
|
21
21
|
#
|
|
22
|
+
# Stale-break atomicity (R-B1, 2026-09-21): the break's read→rm→create is
|
|
23
|
+
# serialized on a sidecar flock ($LOCK_FILE.flock) and the lease is re-read
|
|
24
|
+
# inside the critical section — the old unconditional rm before the noclobber
|
|
25
|
+
# create let two concurrent reclaimers both win. Refresh rewrites via
|
|
26
|
+
# temp-file + atomic rename so a concurrent reader never sees a torn file,
|
|
27
|
+
# and takes the same sidecar flock so a refresh can never clobber a
|
|
28
|
+
# reclaimer's fresh lock in its read→mv gap (2026-09-21): refresh's
|
|
29
|
+
# identity-only re-check closes the clobber because a reclaim always changes
|
|
30
|
+
# task_id. Refresh never checks expiry — a long build that outran the lease
|
|
31
|
+
# legitimately revives its lock here (publish-npm.sh step 5b relies on it).
|
|
32
|
+
# Release (review pass 2, 2026-09-21) takes the same sidecar flock with the
|
|
33
|
+
# identity re-check inside the critical section, so a release can never rm
|
|
34
|
+
# a reclaimer's fresh lock in its read→rm gap.
|
|
35
|
+
#
|
|
22
36
|
# Usage:
|
|
23
37
|
# merge-lock.sh acquire <task_id> <holder> — acquire the lock (0 ok, 1 held)
|
|
24
38
|
# merge-lock.sh refresh <task_id> — extend the lease (holder task only)
|
|
@@ -44,6 +58,7 @@ fi
|
|
|
44
58
|
|
|
45
59
|
LOCK_DIR="$CREW_REPO/.worktrees"
|
|
46
60
|
LOCK_FILE="$LOCK_DIR/.merge-lock"
|
|
61
|
+
FLOCK_FILE="$LOCK_FILE.flock" # sidecar serializing stale-lease breaks (R-B1) and refresh rewrites (2026-09-21)
|
|
47
62
|
LOG_FILE="$CREW_HOME/.merge-lock.log"
|
|
48
63
|
LEASE_SECONDS="${MERGE_LOCK_LEASE_SECONDS:-600}"
|
|
49
64
|
|
|
@@ -127,55 +142,152 @@ case "$cmd" in
|
|
|
127
142
|
exit 1
|
|
128
143
|
fi
|
|
129
144
|
# Expired or malformed lease — break it (logged) and re-acquire.
|
|
145
|
+
# R-B1 (2026-09-21): read→rm→create is serialized on the sidecar flock.
|
|
146
|
+
# The rm used to run before the noclobber create outside any atomic op,
|
|
147
|
+
# so two concurrent reclaimers could both win: B read the stale lease,
|
|
148
|
+
# A reclaimed (rm + create, exit 0), B's rm deleted A's fresh lock, and
|
|
149
|
+
# B's create then succeeded too. The lease is re-read INSIDE the
|
|
150
|
+
# critical section — a contender may have refreshed, released, or
|
|
151
|
+
# reclaimed while we waited on the flock.
|
|
130
152
|
echo "STALE: lease for ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) expired — breaking" >&2
|
|
131
153
|
log_op acquire "task_id=$task_id holder=$holder result=STALE-BREAK previous_task=${lock_task_id:-unknown} previous_holder=${lock_holder:-unknown}"
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
stale_task="${lock_task_id:-unknown}"
|
|
155
|
+
mkdir -p "$LOCK_DIR"
|
|
156
|
+
# Subshell exit codes: 0 reclaimed; 10 the re-read found a live lease;
|
|
157
|
+
# 11 lost the noclobber create to a fresh acquirer racing the break.
|
|
158
|
+
reclaim_code=0
|
|
159
|
+
(
|
|
160
|
+
exec 200>"$FLOCK_FILE"
|
|
161
|
+
flock -x 200
|
|
162
|
+
if read_lock; then
|
|
163
|
+
recheck="$(lock_remaining)"
|
|
164
|
+
if [ "$recheck" != "unknown" ] && [ "$recheck" -gt 0 ]; then
|
|
165
|
+
exit 10
|
|
166
|
+
fi
|
|
167
|
+
fi
|
|
168
|
+
rm -f "$LOCK_FILE"
|
|
169
|
+
if write_lock "$task_id" "$holder"; then
|
|
170
|
+
exit 0
|
|
171
|
+
else
|
|
172
|
+
exit 11
|
|
173
|
+
fi
|
|
174
|
+
) || reclaim_code=$?
|
|
175
|
+
case "$reclaim_code" in
|
|
176
|
+
0)
|
|
177
|
+
log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED-RECLAIMED previous_task=$stale_task"
|
|
178
|
+
echo "ACQUIRED by $task_id (holder $holder; reclaimed expired lease from $stale_task)"
|
|
179
|
+
exit 0
|
|
180
|
+
;;
|
|
181
|
+
10)
|
|
182
|
+
# Re-read found a live lease inside the critical section: another
|
|
183
|
+
# contender refreshed or reclaimed while we waited on the flock.
|
|
184
|
+
read_lock 2>/dev/null || true
|
|
185
|
+
if [ -n "$lock_task_id" ]; then
|
|
186
|
+
live_remaining="$(lock_remaining)"
|
|
187
|
+
log_op acquire "task_id=$task_id holder=$holder result=HELD-RECHECK holder_task=$lock_task_id holder_id=$lock_holder remaining=${live_remaining}s"
|
|
188
|
+
echo "HELD by $lock_task_id (holder $lock_holder, ${live_remaining}s of lease remaining)"
|
|
189
|
+
else
|
|
190
|
+
log_op acquire "task_id=$task_id holder=$holder result=HELD-RECHECK holder_task=unknown"
|
|
191
|
+
echo "HELD — lost the reclaim race to another contender"
|
|
192
|
+
fi
|
|
193
|
+
exit 1
|
|
194
|
+
;;
|
|
195
|
+
*)
|
|
196
|
+
# Lost the noclobber create to a fresh acquirer racing the break:
|
|
197
|
+
# whoever won the create holds the lock now.
|
|
198
|
+
read_lock 2>/dev/null || true
|
|
199
|
+
log_op acquire "task_id=$task_id holder=$holder result=HELD-RACE holder_task=${lock_task_id:-unknown}"
|
|
200
|
+
echo "HELD by ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) — lost reclaim race"
|
|
201
|
+
exit 1
|
|
202
|
+
;;
|
|
203
|
+
esac
|
|
143
204
|
;;
|
|
144
205
|
refresh)
|
|
145
206
|
[ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
207
|
+
# 2026-09-21: read→identity-check→rewrite is serialized on the sidecar
|
|
208
|
+
# flock against acquire's reclaim — a holder refreshing after lease
|
|
209
|
+
# expiry could otherwise clobber a reclaimer's fresh lock inside the
|
|
210
|
+
# read→mv gap. The re-check inside the critical section is IDENTITY-ONLY:
|
|
211
|
+
# a reclaim always changes task_id, so identity alone closes the clobber.
|
|
212
|
+
# There is deliberately NO expiry fail-closed check — a long build that
|
|
213
|
+
# outran the lease legitimately revives its lock here (publish-npm.sh
|
|
214
|
+
# step 5b and the workflow Publish prompt rely on it). Fail closed (exit
|
|
215
|
+
# 1) only if identity no longer matches.
|
|
216
|
+
refresh_code=0
|
|
217
|
+
(
|
|
218
|
+
exec 200>"$FLOCK_FILE"
|
|
219
|
+
flock -x 200
|
|
220
|
+
if ! read_lock; then
|
|
221
|
+
log_op refresh "task_id=$task_id result=NO-LOCK"
|
|
222
|
+
echo "ERROR: no lock to refresh"
|
|
223
|
+
exit 1
|
|
224
|
+
fi
|
|
225
|
+
if [ "$lock_task_id" != "$task_id" ]; then
|
|
226
|
+
log_op refresh "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
|
|
227
|
+
echo "ERROR: lock held by $lock_task_id, not $task_id"
|
|
228
|
+
exit 1
|
|
229
|
+
fi
|
|
230
|
+
# Extend the lease: acquired_at=now, holder and lease window unchanged.
|
|
231
|
+
# Write-temp + atomic rename (2026-09-21): a truncating rewrite lets a
|
|
232
|
+
# concurrent reader observe a torn (partially written) file and misread
|
|
233
|
+
# the lease as malformed — funneling it spuriously into the reclaim path.
|
|
234
|
+
tmp_lock="$(mktemp "$LOCK_DIR/.merge-lock.tmp.XXXXXX")" \
|
|
235
|
+
|| { echo "ERROR: cannot stage lock refresh"; exit 1; }
|
|
236
|
+
printf 'task_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\n' \
|
|
237
|
+
"$lock_task_id" "$lock_holder" "$(date +%s)" "${lock_lease_seconds:-$LEASE_SECONDS}" > "$tmp_lock"
|
|
238
|
+
mv -f "$tmp_lock" "$LOCK_FILE"
|
|
239
|
+
log_op refresh "task_id=$task_id holder=$lock_holder result=REFRESHED"
|
|
240
|
+
echo "REFRESHED by $task_id (holder $lock_holder)"
|
|
241
|
+
exit 0
|
|
242
|
+
) || refresh_code=$?
|
|
243
|
+
exit "$refresh_code"
|
|
162
244
|
;;
|
|
163
245
|
release)
|
|
164
246
|
[ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
247
|
+
# 2026-09-21 (review pass 2): read→identity-check→rm is serialized on the
|
|
248
|
+
# sidecar flock — the same double-hold race class as R-B1 is reachable
|
|
249
|
+
# here: releaser reads, reclaimer breaks and creates, releaser's rm then
|
|
250
|
+
# deletes the reclaimer's fresh lock. The identity re-check runs INSIDE
|
|
251
|
+
# the critical section.
|
|
252
|
+
release_code=0
|
|
253
|
+
# The identity check and rm run in a subshell, so the holder name is
|
|
254
|
+
# ferried out on stdout (subshell variables never reach the parent).
|
|
255
|
+
release_out="$(
|
|
256
|
+
(
|
|
257
|
+
exec 200>"$FLOCK_FILE"
|
|
258
|
+
flock -x 200
|
|
259
|
+
if ! read_lock; then
|
|
260
|
+
exit 10
|
|
261
|
+
fi
|
|
262
|
+
if [ "$lock_task_id" != "$task_id" ]; then
|
|
263
|
+
exit 11
|
|
264
|
+
fi
|
|
265
|
+
printf 'holder=%s\n' "$lock_holder"
|
|
266
|
+
rm -f "$LOCK_FILE"
|
|
267
|
+
exit 0
|
|
268
|
+
)
|
|
269
|
+
)" || release_code=$?
|
|
270
|
+
release_holder="${release_out#holder=}"
|
|
271
|
+
case "$release_code" in
|
|
272
|
+
0)
|
|
273
|
+
log_op release "task_id=$task_id holder=$release_holder result=RELEASED"
|
|
274
|
+
echo "RELEASED by $task_id"
|
|
275
|
+
exit 0
|
|
276
|
+
;;
|
|
277
|
+
10)
|
|
278
|
+
log_op release "task_id=$task_id result=NOT-LOCKED"
|
|
279
|
+
echo "RELEASED (was not locked)"
|
|
280
|
+
exit 0
|
|
281
|
+
;;
|
|
282
|
+
*)
|
|
283
|
+
# Re-read inside the critical section: identity checked against the
|
|
284
|
+
# lock as it stands now, not as it stood before the flock.
|
|
285
|
+
read_lock 2>/dev/null || true
|
|
286
|
+
log_op release "task_id=$task_id result=NOT-HOLDER holder_task=${lock_task_id:-unknown}"
|
|
287
|
+
echo "ERROR: lock held by ${lock_task_id:-unknown}, not $task_id"
|
|
288
|
+
exit 1
|
|
289
|
+
;;
|
|
290
|
+
esac
|
|
179
291
|
;;
|
|
180
292
|
status)
|
|
181
293
|
if ! read_lock; then
|
|
@@ -1,64 +1,157 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// parked. Readers (scan-publish-unknown in lib/crew-api.js) consult this;
|
|
4
|
-
// writers assert against it (lib/verify-publish.js's terminal() fails loud
|
|
5
|
-
// before emitting a verb that is not in the registry). Add a note here when
|
|
6
|
-
// a new terminal verb is introduced — the scan recognizes it with no other
|
|
7
|
-
// change.
|
|
1
|
+
// Publish-note vocabulary registry (D7, 2026-09-19; 0.14.6 version-acknowledgement
|
|
2
|
+
// rewrite, 2026-09-20). Explicit state, not prose.
|
|
8
3
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
4
|
+
// 0.14.6 replaced the content-verdict/readback architecture with Eric's
|
|
5
|
+
// publication contract: exact version acknowledgement is the sole positive
|
|
6
|
+
// completion criterion ("If we hear that the artifact acknowledges our
|
|
7
|
+
// version, that's it. We don't verify against content."). Provenance now
|
|
8
|
+
// certifies that an issuance request was ACKNOWLEDGED — never that bytes
|
|
9
|
+
// matched.
|
|
10
|
+
//
|
|
11
|
+
// Two registries:
|
|
12
|
+
// TERMINAL_PUBLISH_NOTES — every `publish: …` note the ack/intent scans
|
|
13
|
+
// treat as terminally parked. Readers consult this; a recognized
|
|
14
|
+
// terminal note is skipped as terminal with its meaning, never as
|
|
15
|
+
// unrecognized. Legacy verbs from the retired content-verdict machine
|
|
16
|
+
// stay recognized (marked below) so old note history never becomes
|
|
17
|
+
// "unrecognized" — the new machine never writes them.
|
|
18
|
+
// TRANSITIONAL_PUBLISH_NOTES — every non-terminal state-carrying
|
|
19
|
+
// `publish: …` note the 0.14.6 machine writes.
|
|
20
|
+
//
|
|
21
|
+
// Writers are split in two (2026-09-20 REVIEW): deterministic code writes
|
|
22
|
+
// through writeGuardedPublishNote (asserts CODE_WRITABLE_PUBLISH_NOTES —
|
|
23
|
+
// transitional + the four current terminals); tick prose writes only
|
|
24
|
+
// through the `record-publish-note` crew-api command (asserts
|
|
25
|
+
// WRITABLE_PUBLISH_NOTES — transitional ONLY, so prose can never mint a
|
|
26
|
+
// terminal state). A third prose path, `log-event` with type "note", is
|
|
27
|
+
// mechanically rejected for any `publish:` verb — notes are advisory, but
|
|
28
|
+
// only the closed writer may mint state-carrying ones.
|
|
29
|
+
//
|
|
30
|
+
// The one gap: the workflow's `publish: publish-requested` park note is
|
|
31
|
+
// written by the workflow's own parkTask plumbing, not through crew-api —
|
|
32
|
+
// the intent scan validates its shape (commit + attempt) against the intent
|
|
33
|
+
// ledger entry when claiming, which is the mechanical guard for that path.
|
|
34
|
+
//
|
|
35
|
+
// Add a note here when a new verb is introduced — readers recognize it with
|
|
36
|
+
// no other change, and writers cannot emit it without registering it.
|
|
16
37
|
//
|
|
17
38
|
// ESM, no shebang, no side effects on import (import-safe module — bare
|
|
18
39
|
// `node publish-note-vocabulary.js` exits 0).
|
|
40
|
+
//
|
|
41
|
+
// The four 0.14.6 terminals the machine's own deterministic code writes
|
|
42
|
+
// (never tick prose — see the writer split above).
|
|
43
|
+
export const CODE_TERMINAL_PUBLISH_NOTES = [
|
|
44
|
+
// The artifact acknowledged the attempt's exact version on disk.
|
|
45
|
+
// Provenance is stamped; the task re-queues. (2026-09-20 REVIEW: disk
|
|
46
|
+
// is the sole positive evidence — the builder-report path was circular.)
|
|
47
|
+
"publish: version-acknowledged",
|
|
48
|
+
// The platform explicitly refused the tick worker's directly-issued edit.
|
|
49
|
+
// Honest terminal state — no re-issue; parked for human attention.
|
|
50
|
+
"publish: publish-refused",
|
|
51
|
+
// The 2-hour acknowledgement budget from first issuance is exhausted with
|
|
52
|
+
// no acknowledgement (unobserved within budget — the edit may still have
|
|
53
|
+
// landed). Parked for human attention.
|
|
54
|
+
"publish: version-timeout",
|
|
55
|
+
// One-party intent terminal: the diff for the committed intent cannot be
|
|
56
|
+
// (re)generated byte-identically — the staged file is missing or corrupt,
|
|
57
|
+
// or the base..commit range cannot be regenerated. Never re-claim, never
|
|
58
|
+
// loop; parked for human attention.
|
|
59
|
+
"publish: publish-unissuable",
|
|
60
|
+
];
|
|
19
61
|
export const TERMINAL_PUBLISH_NOTES = [
|
|
62
|
+
...CODE_TERMINAL_PUBLISH_NOTES,
|
|
63
|
+
// Legacy terminals (retired content-verdict/unknown-recovery machine):
|
|
64
|
+
// recognized so old note history skips as terminal, never written anew.
|
|
20
65
|
"publish: ambiguous",
|
|
21
66
|
"publish: retry-superseded",
|
|
22
67
|
"publish: retry-refused",
|
|
23
68
|
"publish: verified",
|
|
24
69
|
"publish: superseded",
|
|
25
|
-
// One-party publish refusal (2026-09-20, blocker 22): the session-carrying
|
|
26
|
-
// tick worker issued the edit directly and the platform refused it.
|
|
27
|
-
// Honest terminal state — no re-issue, no park-unknown; the task stays
|
|
28
|
-
// parked for human attention.
|
|
29
|
-
"publish: publish-refused",
|
|
30
|
-
// Verification pipeline's terminal content verdict (verify-publish.js).
|
|
31
|
-
// The task stays parked for human attention; unknown-recovery never
|
|
32
|
-
// re-enters it. Added 2026-09-19 (Room #23 J1/J3: was mislogged as
|
|
33
|
-
// unrecognized-publish-note).
|
|
34
70
|
"publish: verification-failed",
|
|
35
|
-
// (2026-09-20, critic-0145 F-O2) One-party intent terminal: the diff for
|
|
36
|
-
// the committed intent cannot be (re)generated byte-identically — the
|
|
37
|
-
// staged file is missing or corrupt, or the base..commit range cannot be
|
|
38
|
-
// regenerated. Never re-claim, never loop on an hourly reclaim; parked
|
|
39
|
-
// for human attention.
|
|
40
|
-
"publish: publish-unissuable",
|
|
41
|
-
// (2026-09-20, critic-0145 F-R3) One-party intent terminal: a re-claimed
|
|
42
|
-
// intent arrived with no pre-issuance manifest baseline, so the freshness
|
|
43
|
-
// comparison is unperformable — the tick can neither prove a dead tick's
|
|
44
|
-
// issuance nor safely re-issue. Never blind-issue, never loop
|
|
45
|
-
// indefinitely; parked for human attention.
|
|
46
71
|
"publish: intent-unverifiable",
|
|
47
72
|
];
|
|
48
73
|
export const TERMINAL_NOTE_MEANINGS = {
|
|
49
|
-
"publish:
|
|
50
|
-
"publish:
|
|
51
|
-
"publish:
|
|
52
|
-
"publish: verified": "publish verified and stamped; task re-queued (terminal for the scan)",
|
|
53
|
-
"publish: superseded": "HEAD moved past the attempt; recovery aborted; parked for human attention",
|
|
54
|
-
"publish: publish-refused": "one-party publish terminal: the platform refused the tick worker's directly-issued edit; parked for human attention",
|
|
55
|
-
"publish: verification-failed": "verification pipeline's terminal content verdict — parked for human attention",
|
|
74
|
+
"publish: version-acknowledged": "the artifact acknowledged this attempt's exact version — provenance stamped; task re-queued (terminal for the scans)",
|
|
75
|
+
"publish: publish-refused": "the platform refused the tick worker's directly-issued edit (or a builder report carried the version + an explicit refusal) — parked for human attention",
|
|
76
|
+
"publish: version-timeout": "the 2-hour acknowledgement budget from first issuance expired with no version acknowledgement (unobserved within budget — the edit may still have landed; the outcome is unknown) — parked for human attention",
|
|
56
77
|
"publish: publish-unissuable": "one-party intent terminal: the intent diff cannot be (re)generated byte-identically; parked for human attention",
|
|
57
|
-
"publish:
|
|
78
|
+
"publish: ambiguous": "legacy (retired unknown-recovery): could not prove the drop; parked for human attention",
|
|
79
|
+
"publish: retry-superseded": "legacy (retired retry protocol): superseded; parked for human attention",
|
|
80
|
+
"publish: retry-refused": "legacy (retired retry protocol): platform refused the re-issued edit; parked for human attention",
|
|
81
|
+
"publish: verified": "legacy (retired content-verdict pipeline): publish verified and stamped; task re-queued",
|
|
82
|
+
"publish: superseded": "legacy (retired unknown-recovery): HEAD moved past the attempt; recovery aborted; parked for human attention",
|
|
83
|
+
"publish: verification-failed": "legacy (retired content-verdict pipeline): terminal content verdict — parked for human attention",
|
|
84
|
+
"publish: intent-unverifiable": "legacy (retired manifest-freshness path): re-claimed intent with no manifest baseline; parked for human attention",
|
|
58
85
|
};
|
|
86
|
+
// 0.14.6 transitional notes — every non-terminal state-carrying note the
|
|
87
|
+
// machine writes. The ack scan (scan-ack-pending) is atomic code and needs
|
|
88
|
+
// no claim note of its own.
|
|
89
|
+
export const TRANSITIONAL_PUBLISH_NOTES = [
|
|
90
|
+
// The workflow parked at Publish with a checksummed publish-intent ledger
|
|
91
|
+
// entry (carries the per-attempt version). The intent scan claims these.
|
|
92
|
+
"publish: publish-requested",
|
|
93
|
+
// The intent scan claimed this tick's issuance lease (1 hour).
|
|
94
|
+
"publish: publish-intent-claimed",
|
|
95
|
+
// The intent's staged diff was computed against a superseded base —
|
|
96
|
+
// re-queued for the workflow's Publish to re-prepare. Never issued.
|
|
97
|
+
"publish: publish-base-stale",
|
|
98
|
+
// The tick worker issued the edit and recorded it (issuer-stamped
|
|
99
|
+
// submitted ledger entry). The ack scan owns the task from here.
|
|
100
|
+
"publish: edit-issued",
|
|
101
|
+
];
|
|
102
|
+
// The prose-writable set (0.14.6 §1.5, closed publish-note writer): the ONLY
|
|
103
|
+
// verbs tick prose may mint, via the `record-publish-note` crew-api command.
|
|
104
|
+
// Transitional notes only — prose can never mint a terminal state. Legacy
|
|
105
|
+
// terminals are recognized (read-side, so old note history still skips as
|
|
106
|
+
// terminal) but NOT writable — history stays readable; nothing new may be
|
|
107
|
+
// written in the old tongue.
|
|
108
|
+
export const WRITABLE_PUBLISH_NOTES = [
|
|
109
|
+
...TRANSITIONAL_PUBLISH_NOTES,
|
|
110
|
+
];
|
|
111
|
+
// The code-writable set: deterministic code (the scans and the guarded
|
|
112
|
+
// record-* commands) writes through writeGuardedPublishNote, which asserts
|
|
113
|
+
// here — transitional notes plus the four current terminals the machine
|
|
114
|
+
// itself transitions to. Legacy terminals are never written anew.
|
|
115
|
+
export const CODE_WRITABLE_PUBLISH_NOTES = [
|
|
116
|
+
...TRANSITIONAL_PUBLISH_NOTES,
|
|
117
|
+
...CODE_TERMINAL_PUBLISH_NOTES,
|
|
118
|
+
];
|
|
119
|
+
function extractPublishVerb(message) {
|
|
120
|
+
const m = /publish:\s*([a-z0-9-]+)/.exec(String(message || ""));
|
|
121
|
+
return m ? `publish: ${m[1]}` : null;
|
|
122
|
+
}
|
|
59
123
|
export function matchTerminalPublishNote(message) {
|
|
60
124
|
for (const note of TERMINAL_PUBLISH_NOTES) {
|
|
61
125
|
if (message.includes(note)) return note; // same substring semantics the scan already uses
|
|
62
126
|
}
|
|
63
127
|
return null;
|
|
64
128
|
}
|
|
129
|
+
// Prose-side guard (0.14.6 §1.5, closed publish-note writer): the
|
|
130
|
+
// `record-publish-note` command asserts here. Returns the matched
|
|
131
|
+
// "publish: <verb>". Throws (usage) on anything else — prose cannot mint
|
|
132
|
+
// new states, and cannot mint terminal states.
|
|
133
|
+
export function assertWritablePublishNote(message) {
|
|
134
|
+
const verb = extractPublishVerb(message);
|
|
135
|
+
if (!verb || !WRITABLE_PUBLISH_NOTES.includes(verb)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`unregistered publish-note verb ${verb === null ? "(none found)" : `'${verb}'`} — ` +
|
|
138
|
+
`tick prose may only mint transitional publish notes (lib/publish-note-vocabulary.js); ` +
|
|
139
|
+
`terminal states are written by deterministic code, never prose. Legacy terminals are recognized but not writable.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return verb;
|
|
143
|
+
}
|
|
144
|
+
// Code-side guard: writeGuardedPublishNote asserts here. Returns the
|
|
145
|
+
// matched "publish: <verb>". Throws on anything else — a registry
|
|
146
|
+
// violation is a caller bug (usage).
|
|
147
|
+
export function assertCodeWritablePublishNote(message) {
|
|
148
|
+
const verb = extractPublishVerb(message);
|
|
149
|
+
if (!verb || !CODE_WRITABLE_PUBLISH_NOTES.includes(verb)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`unregistered publish-note verb ${verb === null ? "(none found)" : `'${verb}'`} — ` +
|
|
152
|
+
`state-carrying publish notes are a closed set (lib/publish-note-vocabulary.js); ` +
|
|
153
|
+
`register the verb before writing it. Legacy terminals are recognized but never written anew.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return verb;
|
|
157
|
+
}
|
package/lib/qa-db.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// qa-db.js — fresh per-run QA database for the local artifact server.
|
|
2
|
+
//
|
|
3
|
+
// Import-safe: no side effects on import. `node qa-db.js` with no args exits
|
|
4
|
+
// 0 (the release entry gate executes bare non-CLI lib modules).
|
|
5
|
+
//
|
|
6
|
+
// `openQaDb(spaceDir)` opens a drizzle db over a FRESH per-run SQLite
|
|
7
|
+
// database, migrated from the space's own `drizzle/` migrations in journal
|
|
8
|
+
// order — the same migration path a fresh production install takes — and
|
|
9
|
+
// never a copy of the shipped app.db. The database file lives in a per-run
|
|
10
|
+
// temp dir, so the server stays read-only w.r.t. the space directory and QA
|
|
11
|
+
// writes can never contaminate production data (the B38 contamination
|
|
12
|
+
// class: audit sessions writing verification data into the live artifact's
|
|
13
|
+
// production DB).
|
|
14
|
+
//
|
|
15
|
+
// Driver: drizzle-orm/sqlite-proxy resolved from the space's own
|
|
16
|
+
// node_modules (so the driver matches the artifact's drizzle version), over
|
|
17
|
+
// node:sqlite (built-in). Zero extra dependencies in the crew release.
|
|
18
|
+
//
|
|
19
|
+
// Proxy contract, verified mechanically against drizzle-orm 0.45.2's
|
|
20
|
+
// COMPILED runtime (sqlite-proxy/driver.js, sqlite-proxy/session.js,
|
|
21
|
+
// utils.js — not just the .d.ts):
|
|
22
|
+
// - utils.js mapResultRow(columns, row, ...) reads row[columnIndex]:
|
|
23
|
+
// rows must be POSITIONAL value arrays in SQL column order. The proxy
|
|
24
|
+
// prepares with { returnArrays: true }, so node:sqlite returns rows
|
|
25
|
+
// as positional arrays — duplicate column names (self-joins, t.*,
|
|
26
|
+
// joins of overlapping schemas) survive by ordinal. The object-keyed
|
|
27
|
+
// default would silently collapse them.
|
|
28
|
+
// - session.js get(): mapGetResult(clientResult.rows) — a miss must be
|
|
29
|
+
// FALSY rows (returns undefined); a hit must be the single row as a
|
|
30
|
+
// positional array (it is NOT wrapped — mapGetResult treats rows as the
|
|
31
|
+
// row itself).
|
|
32
|
+
// - session.js all()/values(): { rows } is destructured and .map'ed, so
|
|
33
|
+
// rows must be an array of positional arrays.
|
|
34
|
+
// - session.js run(): the callback's return is handed to the caller; the
|
|
35
|
+
// declared type is Promise<{rows: any[]}> — {rows: []} is the honest
|
|
36
|
+
// empty shape.
|
|
37
|
+
// - session.js batch(): batchResults.map((result, i) =>
|
|
38
|
+
// preparedQueries[i].mapResult(result, true)) — each item must be one
|
|
39
|
+
// per-query {rows} object in order; mapResult with isFromBatch unwraps
|
|
40
|
+
// rows.rows before the same get/all mapping above.
|
|
41
|
+
// - driver.js drizzle(callback, batchCallback?, config?): the second
|
|
42
|
+
// positional arg is the batch callback when it is a function.
|
|
43
|
+
|
|
44
|
+
import { DatabaseSync } from "node:sqlite";
|
|
45
|
+
import { createRequire } from "node:module";
|
|
46
|
+
import { pathToFileURL } from "node:url";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { existsSync } from "node:fs";
|
|
49
|
+
import { readFile } from "node:fs/promises";
|
|
50
|
+
import { mkdtemp } from "node:fs/promises";
|
|
51
|
+
import { tmpdir } from "node:os";
|
|
52
|
+
|
|
53
|
+
// sqlite-proxy callback over a node:sqlite DatabaseSync. See the contract
|
|
54
|
+
// note at the top of this file.
|
|
55
|
+
export function sqliteProxyCallback(sqlite) {
|
|
56
|
+
const one = async (sql, params, method) => {
|
|
57
|
+
// node:sqlite Statements have no close(); the DatabaseSync owns them
|
|
58
|
+
// and they are reclaimed with it.
|
|
59
|
+
// returnArrays: rows come back as POSITIONAL value arrays, so
|
|
60
|
+
// duplicate column names (self-joins, t.*, joins of overlapping
|
|
61
|
+
// schemas) survive by ordinal. The object-keyed default would
|
|
62
|
+
// silently collapse them — Object.values() would drop the earlier
|
|
63
|
+
// columns entirely.
|
|
64
|
+
const stmt = sqlite.prepare(sql, { returnArrays: true });
|
|
65
|
+
if (method === "run") {
|
|
66
|
+
stmt.run(...params);
|
|
67
|
+
return { rows: [] };
|
|
68
|
+
}
|
|
69
|
+
if (method === "all" || method === "values") {
|
|
70
|
+
return { rows: stmt.all(...params) };
|
|
71
|
+
}
|
|
72
|
+
if (method === "get") {
|
|
73
|
+
const row = stmt.get(...params);
|
|
74
|
+
return row === undefined ? { rows: undefined } : { rows: row };
|
|
75
|
+
}
|
|
76
|
+
throw new Error("sqlite-proxy: unknown method " + method);
|
|
77
|
+
};
|
|
78
|
+
return one;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fresh per-run QA database for spaceDir. Resolves to
|
|
82
|
+
// { db, close } — db is the drizzle instance handed to ctx.db(); close()
|
|
83
|
+
// releases the sqlite handle (best effort on kill). Throws with a clear
|
|
84
|
+
// reason when the space has no drizzle migrations or drizzle-orm cannot be
|
|
85
|
+
// resolved from its node_modules; callers degrade gracefully (the server
|
|
86
|
+
// still boots, ctx.db throws the reason when called).
|
|
87
|
+
export async function openQaDb(spaceDir) {
|
|
88
|
+
const drizzleDir = join(spaceDir, "drizzle");
|
|
89
|
+
const journalPath = join(drizzleDir, "meta", "_journal.json");
|
|
90
|
+
if (!existsSync(journalPath)) {
|
|
91
|
+
throw new Error("no drizzle migrations at " + journalPath);
|
|
92
|
+
}
|
|
93
|
+
const journal = JSON.parse(await readFile(journalPath, "utf8"));
|
|
94
|
+
const entries = [...(journal.entries || [])].sort((a, b) => a.idx - b.idx);
|
|
95
|
+
if (entries.length === 0) {
|
|
96
|
+
throw new Error("empty drizzle journal at " + journalPath);
|
|
97
|
+
}
|
|
98
|
+
// Per-run temp dir — never the space dir, never the shipped app.db.
|
|
99
|
+
const dbDir = await mkdtemp(join(tmpdir(), "serve-artifact-db-"));
|
|
100
|
+
const sqlite = new DatabaseSync(join(dbDir, "qa.db"));
|
|
101
|
+
try {
|
|
102
|
+
sqlite.exec("PRAGMA foreign_keys = ON;");
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const sqlPath = join(drizzleDir, entry.tag + ".sql");
|
|
105
|
+
sqlite.exec(await readFile(sqlPath, "utf8"));
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
try { sqlite.close(); } catch { /* best effort */ }
|
|
109
|
+
throw new Error("migration failed: " + String((err && err.message) || err).slice(0, 200));
|
|
110
|
+
}
|
|
111
|
+
// drizzle-orm/sqlite-proxy resolved from the space's own node_modules, so
|
|
112
|
+
// the driver matches the artifact's drizzle version.
|
|
113
|
+
let db;
|
|
114
|
+
try {
|
|
115
|
+
const spaceRequire = createRequire(join(spaceDir, "package.json"));
|
|
116
|
+
const proxyEntry = spaceRequire.resolve("drizzle-orm/sqlite-proxy");
|
|
117
|
+
const { drizzle } = await import(pathToFileURL(proxyEntry).href);
|
|
118
|
+
const one = sqliteProxyCallback(sqlite);
|
|
119
|
+
db = drizzle(one, async (items) => {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const item of items) out.push(await one(item.sql, item.params, item.method));
|
|
122
|
+
return out;
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
try { sqlite.close(); } catch { /* best effort */ }
|
|
126
|
+
throw new Error("cannot build drizzle db: " + String((err && err.message) || err).slice(0, 200));
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
db,
|
|
130
|
+
close() { try { sqlite.close(); } catch { /* best effort on kill */ } },
|
|
131
|
+
};
|
|
132
|
+
}
|
package/lib/schema.sql
CHANGED
|
@@ -64,6 +64,17 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
64
64
|
deps TEXT NOT NULL DEFAULT '[]',
|
|
65
65
|
filed_by TEXT,
|
|
66
66
|
retry_reset_at TEXT,
|
|
67
|
+
-- Cascade-park attribution (room #26 blocker 36, 2026-09-21). When a task
|
|
68
|
+
-- parks, its todo dependents cascade-park with structured attribution on
|
|
69
|
+
-- the existing 'parked' state — never a new state, never silent todo,
|
|
70
|
+
-- never auto-waived. park_reason is a closed single-column CHECK
|
|
71
|
+
-- ('dep_parked' only); park_dep_id names the parked dep. NULL on both =
|
|
72
|
+
-- ordinary park (human hold). The pairing invariant (reason<->dep)
|
|
73
|
+
-- cannot be a schema CHECK — SQLite forbids cross-column CHECKs on
|
|
74
|
+
-- ADD COLUMN — so it is enforced by crew-api.js, the single writer of
|
|
75
|
+
-- these columns.
|
|
76
|
+
park_reason TEXT CHECK (park_reason IS NULL OR park_reason = 'dep_parked'),
|
|
77
|
+
park_dep_id TEXT,
|
|
67
78
|
created_at TEXT NOT NULL,
|
|
68
79
|
updated_at TEXT NOT NULL
|
|
69
80
|
);
|
|
@@ -108,6 +119,45 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
|
108
119
|
(already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))
|
|
109
120
|
);
|
|
110
121
|
|
|
122
|
+
-- Room #26 blocker 33 (2026-09-21): structured, non-lossy verdict records.
|
|
123
|
+
-- Review verdict grounds were destroyed by the 2000-char session-note
|
|
124
|
+
-- truncation (the workflow slices the worker report before record-phase
|
|
125
|
+
-- writes it as notes) — a park on a verified-correct implementation was
|
|
126
|
+
-- unauditable because the grounds past char 2000 existed nowhere. The fib:
|
|
127
|
+
-- "the review notes are the review record." The notes are lossy by design
|
|
128
|
+
-- (downstream consumers read them: rejectionNotes, the QA backstop, the
|
|
129
|
+
-- dashboard); this table is the lossless record. record-phase optionally
|
|
130
|
+
-- carries the full grounds and writes the row in the same transaction as
|
|
131
|
+
-- the session note, so the record can never be missing when the note
|
|
132
|
+
-- exists. `summary` is the truncated note actually recorded (provenance of
|
|
133
|
+
-- what the lossy path carried); `grounds` is the full worker report,
|
|
134
|
+
-- uncapped. One verdict per recording session: a re-recorded phase for the
|
|
135
|
+
-- same session is the same evidence, so the first write wins (ON CONFLICT
|
|
136
|
+
-- DO NOTHING) and rows are never revised. A new Review execution claims a
|
|
137
|
+
-- new session, so rework rounds and dispatcher retries each get their own
|
|
138
|
+
-- row, ordered by created_at.
|
|
139
|
+
-- `verdict` is the machine-extracted verdict; INDETERMINATE is the
|
|
140
|
+
-- fail-closed extraction failure (re-ask exhausted) — the grounds are still
|
|
141
|
+
-- preserved even though no verdict could be read.
|
|
142
|
+
CREATE TABLE IF NOT EXISTS verdicts (
|
|
143
|
+
id TEXT PRIMARY KEY,
|
|
144
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
145
|
+
step TEXT NOT NULL,
|
|
146
|
+
attempt INTEGER NOT NULL CHECK (attempt >= 0),
|
|
147
|
+
reviewer TEXT NOT NULL,
|
|
148
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('PASS', 'FAIL', 'INDETERMINATE')),
|
|
149
|
+
grounds TEXT NOT NULL,
|
|
150
|
+
summary TEXT NOT NULL,
|
|
151
|
+
-- Room #26 blocker 34 (redesign): what the review actually examined —
|
|
152
|
+
-- mechanical-fail | branch-diff | runtime-state-none | frozen-merge:<sha>.
|
|
153
|
+
-- NULL = not a classified review (e.g. docs.js path, legacy rows).
|
|
154
|
+
review_basis TEXT,
|
|
155
|
+
session_id TEXT NOT NULL,
|
|
156
|
+
created_at TEXT NOT NULL,
|
|
157
|
+
UNIQUE(session_id)
|
|
158
|
+
);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_verdicts_task ON verdicts(task_id);
|
|
160
|
+
|
|
111
161
|
CREATE TABLE IF NOT EXISTS events (
|
|
112
162
|
id TEXT PRIMARY KEY,
|
|
113
163
|
type TEXT NOT NULL CHECK (type IN
|
|
@@ -205,3 +255,22 @@ CREATE TABLE IF NOT EXISTS platform_run_tasks (
|
|
|
205
255
|
linked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
206
256
|
);
|
|
207
257
|
CREATE INDEX IF NOT EXISTS platform_run_tasks_task_id_idx ON platform_run_tasks(task_id);
|
|
258
|
+
|
|
259
|
+
-- Builder reports (0.14.6, 2026-09-20). The tick worker records the artifact
|
|
260
|
+
-- platform's report text verbatim after each directly-issued edit via the
|
|
261
|
+
-- `record-builder-report` command. The command mechanically rejects unless
|
|
262
|
+
-- the report text contains the exact per-attempt version, so every row here is
|
|
263
|
+
-- a version-keyed refusal the ack scan can evaluate: outcome 'refused' + a
|
|
264
|
+
-- version matching an issued attempt = terminal publish-refused. (The old
|
|
265
|
+
-- 'acknowledged' outcome was cut 2026-09-20 REVIEW as circular — disk is the
|
|
266
|
+
-- sole positive evidence.) Rows are evidence, never deleted by the scans.
|
|
267
|
+
CREATE TABLE IF NOT EXISTS builder_reports (
|
|
268
|
+
id TEXT PRIMARY KEY,
|
|
269
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
270
|
+
version TEXT NOT NULL,
|
|
271
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('acknowledged', 'refused')),
|
|
272
|
+
report_text TEXT NOT NULL,
|
|
273
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
274
|
+
);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS builder_reports_task_id_idx ON builder_reports(task_id);
|
|
276
|
+
CREATE INDEX IF NOT EXISTS builder_reports_version_idx ON builder_reports(version);
|