muse-crew 0.6.1 → 0.6.3
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 +9 -0
- package/lib/AGENTS.md +1 -1
- package/lib/test-verify-merge.sh +75 -0
- package/lib/worktree-lifecycle.sh +38 -1
- package/package.json +1 -1
- package/workflows/bugfix.js +59 -8
- package/workflows/chore.js +59 -8
- package/workflows/standard.js +59 -8
package/API.md
CHANGED
|
@@ -294,6 +294,15 @@ Artifact Publish passes only when both are true:
|
|
|
294
294
|
|
|
295
295
|
Verification fails closed. If the provenance is missing, unreadable, or points at a different commit than the integrated HEAD, the workflow parks the task — it does not trust the worker's prose. Narrative remains useful but cannot independently establish deployment truth.
|
|
296
296
|
|
|
297
|
+
### Integrate merge verification
|
|
298
|
+
|
|
299
|
+
Integrate passes only when both are true:
|
|
300
|
+
|
|
301
|
+
1. The worker declares success.
|
|
302
|
+
2. Mechanical verification confirms the task branch tip is an ancestor of `main`: the workflow runs the lifecycle script's `verify-merge` command, which resolves the branch through the crew registry (never by reconstructing `task/<id>`), and matches the `VERIFIED:` marker on the script's own stdout with a regex — never by reading agent prose.
|
|
303
|
+
|
|
304
|
+
A genuine empty-diff Integrate (no commits ahead of `main` — a runtime-state deliverable) verifies vacuously: the branch tip is then an ancestor of `main`. Verification failure is an operational step failure, not a park: the dispatcher retries Integrate under its consecutive-failure cap, and the retry finds the commits still on the branch and performs the real merge.
|
|
305
|
+
|
|
297
306
|
---
|
|
298
307
|
|
|
299
308
|
## Not part of this API
|
package/lib/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
5
5
|
- `build-registry.js` — deterministic extractor that generates `workflows/registry.json` (workflow step registry) from the workflow files' `meta` blocks at release time; invoked by `crew-release.sh` deploy
|
|
6
6
|
- `crew-release.sh` — immutable release manager: deploy, rollback, prune
|
|
7
7
|
- `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
|
|
8
|
-
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/status/post-deploy/refresh-lock/lock-status over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or `LOCKED by <holder> since <ts> (pid <pid>)`, always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
|
|
8
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/refresh-lock/lock-status over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or `LOCKED by <holder> since <ts> (pid <pid>)`, always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
|
|
9
9
|
- `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight) on a scratch repo
|
|
10
10
|
- `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
|
|
11
11
|
- `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# test-verify-merge.sh — regression tests for the verify-merge lifecycle
|
|
3
|
+
# command (bug b1b1f919: Integrate completed on empty merge while approved
|
|
4
|
+
# commits were still stranded on the task branch).
|
|
5
|
+
#
|
|
6
|
+
# Fixture: a scratch repo with main + prepared task branches.
|
|
7
|
+
# 1. missing branch → ERROR, non-zero
|
|
8
|
+
# 2. empty-diff branch (no commits ahead of main) → VERIFIED, exit 0
|
|
9
|
+
# 3. branch with commits ahead, not merged → NOT_MERGED, non-zero
|
|
10
|
+
# 4. after merging the branch → VERIFIED, exit 0
|
|
11
|
+
# 5. registry-divergent branch name → resolves via registry, not via
|
|
12
|
+
# reconstructed "task/<id>" (verifying the wrong branch is the same
|
|
13
|
+
# bug in different clothes)
|
|
14
|
+
# 6. invalid task id → rejected
|
|
15
|
+
set -u
|
|
16
|
+
|
|
17
|
+
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
18
|
+
LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
|
|
19
|
+
|
|
20
|
+
fail() { echo "FAIL: $1"; exit 1; }
|
|
21
|
+
|
|
22
|
+
T=/tmp/crew-verify-merge-test
|
|
23
|
+
rm -rf "$T"
|
|
24
|
+
mkdir -p "$T"
|
|
25
|
+
cd "$T"
|
|
26
|
+
git init -q -b main .
|
|
27
|
+
git config user.email test@test.t
|
|
28
|
+
git config user.name test
|
|
29
|
+
echo x > f.txt
|
|
30
|
+
echo ".worktrees/" > .gitignore
|
|
31
|
+
git add -A
|
|
32
|
+
git commit -qm init
|
|
33
|
+
|
|
34
|
+
export CREW_REPO="$T"
|
|
35
|
+
tid="verifytest1"
|
|
36
|
+
|
|
37
|
+
# 1. missing branch → ERROR, non-zero
|
|
38
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "verify-merge on missing branch exited 0"
|
|
39
|
+
echo "$out" | grep -q '^ERROR: branch task/verifytest1 does not exist' || fail "missing branch: expected ERROR, got: $out"
|
|
40
|
+
|
|
41
|
+
# 2. prepare (empty diff) → VERIFIED, exit 0
|
|
42
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
|
|
43
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge on empty-diff branch exited non-zero: $out"
|
|
44
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "empty-diff: expected VERIFIED, got: $out"
|
|
45
|
+
|
|
46
|
+
# 3. commit on the branch, not merged → NOT_MERGED, non-zero
|
|
47
|
+
echo change >> "$T/.worktrees/$tid/f.txt"
|
|
48
|
+
(cd "$T/.worktrees/$tid" && git commit -qam work)
|
|
49
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "verify-merge on unmerged branch exited 0"
|
|
50
|
+
echo "$out" | grep -q '^NOT_MERGED:' || fail "unmerged: expected NOT_MERGED, got: $out"
|
|
51
|
+
echo "$out" | grep -q '1 commit(s) still ahead of main' || fail "unmerged: expected ahead count, got: $out"
|
|
52
|
+
|
|
53
|
+
# 4. merge the branch → VERIFIED, exit 0
|
|
54
|
+
(cd "$T" && git merge -q --no-ff "task/$tid" -m "merge: $tid")
|
|
55
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge after merge exited non-zero: $out"
|
|
56
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "merged: expected VERIFIED, got: $out"
|
|
57
|
+
|
|
58
|
+
# 5. registry-divergent branch name → resolve_branch wins over reconstruction
|
|
59
|
+
tid2="verifytest2"
|
|
60
|
+
out=$(bash "$LIFECYCLE" prepare "$tid2") || fail "prepare 2 exited non-zero: $out"
|
|
61
|
+
(cd "$T" && git branch -m "task/$tid2" "custom/$tid2")
|
|
62
|
+
sed -i "s|^branch=task/$tid2\$|branch=custom/$tid2|" "$T/.worktrees/.registry/$tid2"
|
|
63
|
+
echo change2 >> "$T/.worktrees/$tid2/f.txt"
|
|
64
|
+
(cd "$T/.worktrees/$tid2" && git commit -qam work2)
|
|
65
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid2" 2>&1) && fail "verify-merge on divergent unmerged branch exited 0"
|
|
66
|
+
echo "$out" | grep -q '^NOT_MERGED: custom/verifytest2 ' || fail "divergent: expected NOT_MERGED on custom/verifytest2, got: $out"
|
|
67
|
+
|
|
68
|
+
# 6. invalid task id rejected
|
|
69
|
+
bash "$LIFECYCLE" verify-merge "bad id!" >/dev/null 2>&1 && fail "verify-merge accepted a bad id"
|
|
70
|
+
|
|
71
|
+
bash "$LIFECYCLE" cleanup "$tid" >/dev/null 2>&1 || true
|
|
72
|
+
(cd "$T" && git branch -D "custom/$tid2" >/dev/null 2>&1) || true
|
|
73
|
+
rm -rf "$T/.worktrees/$tid2" "$T/.worktrees/.registry/$tid2"
|
|
74
|
+
rm -rf "$T"
|
|
75
|
+
echo "verify-merge: OK"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
# prepare <task_id> — create worktree + branch, register ownership
|
|
16
16
|
# inspect <task_id> — diff against main for review
|
|
17
17
|
# integrate <task_id> <commit_msg> — merge lock → merge into main → report commit
|
|
18
|
+
# verify-merge <task_id> — confirm the task branch tip is an ancestor of main (post-step merge check)
|
|
18
19
|
# post-deploy <task_id> — commit builder artifacts, cleanup, release lock
|
|
19
20
|
# cleanup <task_id> — remove worktree + branch (no lock)
|
|
20
21
|
# status <task_id> — report worktree state
|
|
@@ -250,6 +251,41 @@ cmd_integrate() {
|
|
|
250
251
|
echo "LOCK: held by $task_id — release after deploy with post-deploy"
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
cmd_verify_merge() {
|
|
255
|
+
local task_id="$1"
|
|
256
|
+
validate_task_id "$task_id"
|
|
257
|
+
|
|
258
|
+
# Resolve through the registry — never reconstruct "task/<id>" here. The
|
|
259
|
+
# workflow's TASK_BRANCH constant and the registry can diverge, and
|
|
260
|
+
# verifying the wrong branch is the same bug in different clothes
|
|
261
|
+
# (b1b1f919).
|
|
262
|
+
local branch
|
|
263
|
+
branch=$(resolve_branch "$task_id")
|
|
264
|
+
|
|
265
|
+
cd "$REPO"
|
|
266
|
+
|
|
267
|
+
if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
|
|
268
|
+
echo "ERROR: branch $branch does not exist"
|
|
269
|
+
return 1
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
local tip
|
|
273
|
+
tip=$(git rev-parse "$branch")
|
|
274
|
+
|
|
275
|
+
# The merge landed iff the task branch tip is an ancestor of main. A
|
|
276
|
+
# genuine empty-diff Integrate (MERGED_EMPTY: no commits ahead of main)
|
|
277
|
+
# verifies vacuously — the tip is then main itself or behind it.
|
|
278
|
+
if git merge-base --is-ancestor "$branch" main 2>/dev/null; then
|
|
279
|
+
echo "VERIFIED: $branch tip $tip is an ancestor of main — merge landed"
|
|
280
|
+
return 0
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
local ahead
|
|
284
|
+
ahead=$(git rev-list --count "main..$branch")
|
|
285
|
+
echo "NOT_MERGED: $branch tip $tip is not an ancestor of main — $ahead commit(s) still ahead of main"
|
|
286
|
+
return 1
|
|
287
|
+
}
|
|
288
|
+
|
|
253
289
|
cmd_post_deploy() {
|
|
254
290
|
local task_id="$1"
|
|
255
291
|
validate_task_id "$task_id"
|
|
@@ -388,6 +424,7 @@ case "$cmd" in
|
|
|
388
424
|
prepare) cmd_prepare "${1:?task_id required}" ;;
|
|
389
425
|
inspect) cmd_inspect "${1:?task_id required}" ;;
|
|
390
426
|
integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
|
|
427
|
+
verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
|
|
391
428
|
post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
|
|
392
429
|
cleanup) cmd_cleanup "${1:?task_id required}" ;;
|
|
393
430
|
status) cmd_status "${1:?task_id required}" ;;
|
|
@@ -396,7 +433,7 @@ case "$cmd" in
|
|
|
396
433
|
sweep) cmd_sweep "${1:-report}" ;;
|
|
397
434
|
*)
|
|
398
435
|
echo "Usage: worktree-lifecycle.sh <command> [args]"
|
|
399
|
-
echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
|
|
436
|
+
echo "Commands: validate, prepare, inspect, integrate, verify-merge, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
|
|
400
437
|
exit 1
|
|
401
438
|
;;
|
|
402
439
|
esac
|
package/package.json
CHANGED
package/workflows/bugfix.js
CHANGED
|
@@ -323,8 +323,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
|
|
|
323
323
|
// opt-in — a missing or garbled line degrades to "unknown", which callers
|
|
324
324
|
// treat as non-experiential. The task is never parked on a garbled line.
|
|
325
325
|
async function resolveExperiential() {
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
// Triage notes are immutable within a run: cache the resolved outcome —
|
|
327
|
+
// yes, no, AND unknown — so the dashboard lookup runs at most once per
|
|
328
|
+
// run. Without the unknown branch a missing experiential marker would
|
|
329
|
+
// re-fire the same "resolve-experiential-<taskId>" agent key on every
|
|
330
|
+
// Capture/Map entry and throw a duplicate replay key error.
|
|
331
|
+
if (experientialResolved !== null) return experientialResolved;
|
|
332
|
+
if (isExperiential === true) return (experientialResolved = "yes");
|
|
333
|
+
if (isExperiential === false) return (experientialResolved = "no");
|
|
328
334
|
var expCheck = null;
|
|
329
335
|
try {
|
|
330
336
|
expCheck = await agent(
|
|
@@ -340,12 +346,14 @@ async function resolveExperiential() {
|
|
|
340
346
|
);
|
|
341
347
|
} catch (e) {
|
|
342
348
|
log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
|
|
343
|
-
|
|
349
|
+
experientialResolved = "unknown";
|
|
350
|
+
return experientialResolved;
|
|
344
351
|
}
|
|
345
352
|
var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
|
|
346
|
-
if (marker === true)
|
|
347
|
-
if (marker === false)
|
|
348
|
-
|
|
353
|
+
if (marker === true) experientialResolved = "yes";
|
|
354
|
+
else if (marker === false) experientialResolved = "no";
|
|
355
|
+
else experientialResolved = "unknown";
|
|
356
|
+
return experientialResolved;
|
|
349
357
|
}
|
|
350
358
|
// Baseline evidence status: reads the task's note events for the exact
|
|
351
359
|
// protocol prefixes (explicit state, never English matching). Returns
|
|
@@ -456,6 +464,9 @@ let mapperSpec = "";
|
|
|
456
464
|
// plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
|
|
457
465
|
// a Map-gate bounce re-runs Capture/Map in the same run).
|
|
458
466
|
let isExperiential = null;
|
|
467
|
+
// Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
|
|
468
|
+
// notes are immutable within a run, so the lookup runs at most once.
|
|
469
|
+
let experientialResolved = null;
|
|
459
470
|
let captureTargets = "";
|
|
460
471
|
let mapGateBounceCount = 0;
|
|
461
472
|
// Merge-time versioning: the release decision is extracted deterministically
|
|
@@ -544,7 +555,7 @@ while (i < STEPS.length) {
|
|
|
544
555
|
"Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
|
|
545
556
|
"Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
|
|
546
557
|
{
|
|
547
|
-
key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
|
|
558
|
+
key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
548
559
|
label: "Checking project before " + step.name,
|
|
549
560
|
schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
|
|
550
561
|
}
|
|
@@ -1331,7 +1342,47 @@ while (i < STEPS.length) {
|
|
|
1331
1342
|
passed: verdictPassed !== null ? verdictPassed : true,
|
|
1332
1343
|
summary: workerText
|
|
1333
1344
|
};
|
|
1334
|
-
|
|
1345
|
+
let passed = stepResult.passed === true;
|
|
1346
|
+
|
|
1347
|
+
// Deterministic integrate verification: the agent cannot self-certify a
|
|
1348
|
+
// merge. After the Integrate agent claims success, the workflow confirms
|
|
1349
|
+
// mechanically that the task branch tip is an ancestor of main via the
|
|
1350
|
+
// lifecycle script's verify-merge command (which resolves the branch
|
|
1351
|
+
// through the crew registry — never by reconstructing "task/"+taskId — so
|
|
1352
|
+
// the check cannot verify the wrong branch). The VERIFIED marker is matched
|
|
1353
|
+
// by regex on the script's own stdout; agent prose is never read. This
|
|
1354
|
+
// closes the hole where an agent reported "merged empty" while approved
|
|
1355
|
+
// commits were still stranded on the task branch (bug b1b1f919). A genuine
|
|
1356
|
+
// empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
|
|
1357
|
+
// vacuously — the tip is then an ancestor of main. Verification failure is
|
|
1358
|
+
// an operational step failure, not a park: the dispatcher retries Integrate
|
|
1359
|
+
// under its consecutive-failure cap, and the retry finds the commits still
|
|
1360
|
+
// on the branch and performs the real merge — self-healing.
|
|
1361
|
+
if (step.name === "Integrate" && passed) {
|
|
1362
|
+
var integrateVerifyOut = "";
|
|
1363
|
+
try {
|
|
1364
|
+
var integrateVerifyResult = await agent(
|
|
1365
|
+
"Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
|
|
1366
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
1367
|
+
{ key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
|
|
1368
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
1369
|
+
);
|
|
1370
|
+
integrateVerifyOut = (integrateVerifyResult.output || "").trim();
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
integrateVerifyOut = "";
|
|
1373
|
+
}
|
|
1374
|
+
if (/^VERIFIED:/m.test(integrateVerifyOut)) {
|
|
1375
|
+
log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
|
|
1376
|
+
} else {
|
|
1377
|
+
var integrateVerifyReason = integrateVerifyOut
|
|
1378
|
+
? integrateVerifyOut.split("\n")[0].slice(0, 200)
|
|
1379
|
+
: "verify-merge produced no usable output";
|
|
1380
|
+
log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
|
|
1381
|
+
stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
|
|
1382
|
+
passed = false;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1335
1386
|
// "rejected" is an explicit phase verdict routed through rework (Review/QA,
|
|
1336
1387
|
// and the Build/Reproduce reports that feed them). Integrate/Publish work
|
|
1337
1388
|
// that did not finish is operational — "failed", retryable under the
|
package/workflows/chore.js
CHANGED
|
@@ -321,8 +321,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
|
|
|
321
321
|
// opt-in — a missing or garbled line degrades to "unknown", which callers
|
|
322
322
|
// treat as non-experiential. The task is never parked on a garbled line.
|
|
323
323
|
async function resolveExperiential() {
|
|
324
|
-
|
|
325
|
-
|
|
324
|
+
// Triage notes are immutable within a run: cache the resolved outcome —
|
|
325
|
+
// yes, no, AND unknown — so the dashboard lookup runs at most once per
|
|
326
|
+
// run. Without the unknown branch a missing experiential marker would
|
|
327
|
+
// re-fire the same "resolve-experiential-<taskId>" agent key on every
|
|
328
|
+
// Capture/Map entry and throw a duplicate replay key error.
|
|
329
|
+
if (experientialResolved !== null) return experientialResolved;
|
|
330
|
+
if (isExperiential === true) return (experientialResolved = "yes");
|
|
331
|
+
if (isExperiential === false) return (experientialResolved = "no");
|
|
326
332
|
var expCheck = null;
|
|
327
333
|
try {
|
|
328
334
|
expCheck = await agent(
|
|
@@ -338,12 +344,14 @@ async function resolveExperiential() {
|
|
|
338
344
|
);
|
|
339
345
|
} catch (e) {
|
|
340
346
|
log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
|
|
341
|
-
|
|
347
|
+
experientialResolved = "unknown";
|
|
348
|
+
return experientialResolved;
|
|
342
349
|
}
|
|
343
350
|
var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
|
|
344
|
-
if (marker === true)
|
|
345
|
-
if (marker === false)
|
|
346
|
-
|
|
351
|
+
if (marker === true) experientialResolved = "yes";
|
|
352
|
+
else if (marker === false) experientialResolved = "no";
|
|
353
|
+
else experientialResolved = "unknown";
|
|
354
|
+
return experientialResolved;
|
|
347
355
|
}
|
|
348
356
|
// Baseline evidence status: reads the task's note events for the exact
|
|
349
357
|
// protocol prefixes (explicit state, never English matching). Returns
|
|
@@ -414,6 +422,9 @@ let mapperSpec = "";
|
|
|
414
422
|
// a Map-gate bounce re-runs Capture/Map in the same run). Chore has no QA
|
|
415
423
|
// phase, so there is no visual-verdict function — Capture only.
|
|
416
424
|
let isExperiential = null;
|
|
425
|
+
// Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
|
|
426
|
+
// notes are immutable within a run, so the lookup runs at most once.
|
|
427
|
+
let experientialResolved = null;
|
|
417
428
|
let captureTargets = "";
|
|
418
429
|
let mapGateBounceCount = 0;
|
|
419
430
|
// Merge-time versioning: the release decision is extracted deterministically
|
|
@@ -502,7 +513,7 @@ while (i < STEPS.length) {
|
|
|
502
513
|
"Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
|
|
503
514
|
"Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
|
|
504
515
|
{
|
|
505
|
-
key: "project-check-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
|
|
516
|
+
key: "project-check-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
506
517
|
label: "Checking project before " + step.name,
|
|
507
518
|
schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
|
|
508
519
|
}
|
|
@@ -1207,7 +1218,47 @@ while (i < STEPS.length) {
|
|
|
1207
1218
|
passed: verdictPassed !== null ? verdictPassed : true,
|
|
1208
1219
|
summary: workerText
|
|
1209
1220
|
};
|
|
1210
|
-
|
|
1221
|
+
let passed = stepResult.passed === true;
|
|
1222
|
+
|
|
1223
|
+
// Deterministic integrate verification: the agent cannot self-certify a
|
|
1224
|
+
// merge. After the Integrate agent claims success, the workflow confirms
|
|
1225
|
+
// mechanically that the task branch tip is an ancestor of main via the
|
|
1226
|
+
// lifecycle script's verify-merge command (which resolves the branch
|
|
1227
|
+
// through the crew registry — never by reconstructing "task/"+taskId — so
|
|
1228
|
+
// the check cannot verify the wrong branch). The VERIFIED marker is matched
|
|
1229
|
+
// by regex on the script's own stdout; agent prose is never read. This
|
|
1230
|
+
// closes the hole where an agent reported "merged empty" while approved
|
|
1231
|
+
// commits were still stranded on the task branch (bug b1b1f919). A genuine
|
|
1232
|
+
// empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
|
|
1233
|
+
// vacuously — the tip is then an ancestor of main. Verification failure is
|
|
1234
|
+
// an operational step failure, not a park: the dispatcher retries Integrate
|
|
1235
|
+
// under its consecutive-failure cap, and the retry finds the commits still
|
|
1236
|
+
// on the branch and performs the real merge — self-healing.
|
|
1237
|
+
if (step.name === "Integrate" && passed) {
|
|
1238
|
+
var integrateVerifyOut = "";
|
|
1239
|
+
try {
|
|
1240
|
+
var integrateVerifyResult = await agent(
|
|
1241
|
+
"Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
|
|
1242
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
1243
|
+
{ key: attemptKey("verify-merge-" + taskId, reworkCount), label: "Verifying merge landed",
|
|
1244
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
1245
|
+
);
|
|
1246
|
+
integrateVerifyOut = (integrateVerifyResult.output || "").trim();
|
|
1247
|
+
} catch (e) {
|
|
1248
|
+
integrateVerifyOut = "";
|
|
1249
|
+
}
|
|
1250
|
+
if (/^VERIFIED:/m.test(integrateVerifyOut)) {
|
|
1251
|
+
log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
|
|
1252
|
+
} else {
|
|
1253
|
+
var integrateVerifyReason = integrateVerifyOut
|
|
1254
|
+
? integrateVerifyOut.split("\n")[0].slice(0, 200)
|
|
1255
|
+
: "verify-merge produced no usable output";
|
|
1256
|
+
log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
|
|
1257
|
+
stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
|
|
1258
|
+
passed = false;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1211
1262
|
// "rejected" is an explicit phase verdict routed through rework (Review).
|
|
1212
1263
|
// Integrate/Publish work that did not finish is operational — "failed",
|
|
1213
1264
|
// retryable under the dispatcher's consecutive-failure cap.
|
package/workflows/standard.js
CHANGED
|
@@ -323,8 +323,14 @@ function buildVisualCapturePlan(taskTitle, taskDescription, kind, captureTargets
|
|
|
323
323
|
// opt-in — a missing or garbled line degrades to "unknown", which callers
|
|
324
324
|
// treat as non-experiential. The task is never parked on a garbled line.
|
|
325
325
|
async function resolveExperiential() {
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
// Triage notes are immutable within a run: cache the resolved outcome —
|
|
327
|
+
// yes, no, AND unknown — so the dashboard lookup runs at most once per
|
|
328
|
+
// run. Without the unknown branch a missing experiential marker would
|
|
329
|
+
// re-fire the same "resolve-experiential-<taskId>" agent key on every
|
|
330
|
+
// Capture/Map entry and throw a duplicate replay key error.
|
|
331
|
+
if (experientialResolved !== null) return experientialResolved;
|
|
332
|
+
if (isExperiential === true) return (experientialResolved = "yes");
|
|
333
|
+
if (isExperiential === false) return (experientialResolved = "no");
|
|
328
334
|
var expCheck = null;
|
|
329
335
|
try {
|
|
330
336
|
expCheck = await agent(
|
|
@@ -340,12 +346,14 @@ async function resolveExperiential() {
|
|
|
340
346
|
);
|
|
341
347
|
} catch (e) {
|
|
342
348
|
log("resolveExperiential: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
|
|
343
|
-
|
|
349
|
+
experientialResolved = "unknown";
|
|
350
|
+
return experientialResolved;
|
|
344
351
|
}
|
|
345
352
|
var marker = extractExperiential(expCheck && expCheck.experiential_line ? expCheck.experiential_line : "");
|
|
346
|
-
if (marker === true)
|
|
347
|
-
if (marker === false)
|
|
348
|
-
|
|
353
|
+
if (marker === true) experientialResolved = "yes";
|
|
354
|
+
else if (marker === false) experientialResolved = "no";
|
|
355
|
+
else experientialResolved = "unknown";
|
|
356
|
+
return experientialResolved;
|
|
349
357
|
}
|
|
350
358
|
// Baseline evidence status: reads the task's note events for the exact
|
|
351
359
|
// protocol prefixes (explicit state, never English matching). Returns
|
|
@@ -455,6 +463,9 @@ let mapperSpec = "";
|
|
|
455
463
|
// plan, and the Map-gate bounce counter (keeps agent stable-keys unique when
|
|
456
464
|
// a Map-gate bounce re-runs Capture/Map in the same run).
|
|
457
465
|
let isExperiential = null;
|
|
466
|
+
// Caches all three resolveExperiential() outcomes (yes/no/unknown); Triage
|
|
467
|
+
// notes are immutable within a run, so the lookup runs at most once.
|
|
468
|
+
let experientialResolved = null;
|
|
458
469
|
let captureTargets = "";
|
|
459
470
|
let mapGateBounceCount = 0;
|
|
460
471
|
// Merge-time versioning: the release decision is extracted deterministically
|
|
@@ -543,7 +554,7 @@ while (i < STEPS.length) {
|
|
|
543
554
|
"Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
|
|
544
555
|
"Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
|
|
545
556
|
{
|
|
546
|
-
key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""),
|
|
557
|
+
key: "project-check-" + step.name + (totalReworkCount > 0 ? "-r" + totalReworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
547
558
|
label: "Checking project before " + step.name,
|
|
548
559
|
schema: { type: "object", properties: { project: { type: "string" } }, required: ["project"] }
|
|
549
560
|
}
|
|
@@ -1341,7 +1352,47 @@ while (i < STEPS.length) {
|
|
|
1341
1352
|
passed: verdictPassed !== null ? verdictPassed : true,
|
|
1342
1353
|
summary: workerText
|
|
1343
1354
|
};
|
|
1344
|
-
|
|
1355
|
+
let passed = stepResult.passed === true;
|
|
1356
|
+
|
|
1357
|
+
// Deterministic integrate verification: the agent cannot self-certify a
|
|
1358
|
+
// merge. After the Integrate agent claims success, the workflow confirms
|
|
1359
|
+
// mechanically that the task branch tip is an ancestor of main via the
|
|
1360
|
+
// lifecycle script's verify-merge command (which resolves the branch
|
|
1361
|
+
// through the crew registry — never by reconstructing "task/"+taskId — so
|
|
1362
|
+
// the check cannot verify the wrong branch). The VERIFIED marker is matched
|
|
1363
|
+
// by regex on the script's own stdout; agent prose is never read. This
|
|
1364
|
+
// closes the hole where an agent reported "merged empty" while approved
|
|
1365
|
+
// commits were still stranded on the task branch (bug b1b1f919). A genuine
|
|
1366
|
+
// empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
|
|
1367
|
+
// vacuously — the tip is then an ancestor of main. Verification failure is
|
|
1368
|
+
// an operational step failure, not a park: the dispatcher retries Integrate
|
|
1369
|
+
// under its consecutive-failure cap, and the retry finds the commits still
|
|
1370
|
+
// on the branch and performs the real merge — self-healing.
|
|
1371
|
+
if (step.name === "Integrate" && passed) {
|
|
1372
|
+
var integrateVerifyOut = "";
|
|
1373
|
+
try {
|
|
1374
|
+
var integrateVerifyResult = await agent(
|
|
1375
|
+
"Run: " + LIFECYCLE_ENV + LIFECYCLE + " verify-merge " + taskId + " 2>&1 || true\n" +
|
|
1376
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
1377
|
+
{ key: attemptKey("verify-merge-" + taskId, totalReworkCount), label: "Verifying merge landed",
|
|
1378
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
1379
|
+
);
|
|
1380
|
+
integrateVerifyOut = (integrateVerifyResult.output || "").trim();
|
|
1381
|
+
} catch (e) {
|
|
1382
|
+
integrateVerifyOut = "";
|
|
1383
|
+
}
|
|
1384
|
+
if (/^VERIFIED:/m.test(integrateVerifyOut)) {
|
|
1385
|
+
log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of main");
|
|
1386
|
+
} else {
|
|
1387
|
+
var integrateVerifyReason = integrateVerifyOut
|
|
1388
|
+
? integrateVerifyOut.split("\n")[0].slice(0, 200)
|
|
1389
|
+
: "verify-merge produced no usable output";
|
|
1390
|
+
log("Integrate verification failed for task " + taskId + ": " + integrateVerifyReason + " — marking failed for retry");
|
|
1391
|
+
stepResult.summary = (stepResult.summary || "") + "\nintegrate_verify: FAILED — " + integrateVerifyReason;
|
|
1392
|
+
passed = false;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1345
1396
|
// "rejected" is an explicit phase verdict routed through rework (Review/QA,
|
|
1346
1397
|
// and the Build/Reproduce reports that feed them). Integrate/Publish work
|
|
1347
1398
|
// that did not finish is operational — "failed", retryable under the
|