muse-crew 0.6.6 → 0.6.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/AGENTS.md +1 -1
- package/API.md +53 -9
- package/docs/guide.md +9 -6
- package/docs/publish-verification.md +205 -0
- package/lib/AGENTS.md +5 -5
- package/lib/crew-api.js +1101 -0
- package/lib/crew-release.sh +19 -0
- package/lib/merge-lock.sh +18 -5
- package/lib/publish-npm.sh +6 -5
- package/lib/schema.sql +101 -0
- package/lib/test-merge-lock.sh +13 -0
- package/lib/test-publish-skip.sh +2 -1
- package/lib/test-terminal-cleanup.sh +131 -0
- package/lib/test-verify-merge.sh +1 -0
- package/lib/test-worktree-backend.sh +74 -0
- package/lib/worktree-lifecycle.sh +164 -16
- package/package.json +2 -2
- package/seed/AGENTS.md +0 -1
- package/seed/crons.json +0 -13
- package/workflows/AGENTS.md +4 -2
- package/workflows/bugfix.js +987 -193
- package/workflows/chore.js +936 -177
- package/workflows/crew-dispatch.js +201 -37
- package/workflows/crew-init.js +82 -54
- package/workflows/docs.js +117 -48
- package/workflows/standard.js +970 -195
- package/workflows/tests/read-board-parse.test.mjs +162 -0
- package/lib/orphan-sweep.sh +0 -287
- package/lib/test-orphan-sweep.sh +0 -276
- package/seed/cron-sweep-body-template.md +0 -11
package/workflows/chore.js
CHANGED
|
@@ -26,32 +26,57 @@ const startStepIndex = inputs.start_step_index || 0;
|
|
|
26
26
|
// resolution back via updatetask in the self-claim below.
|
|
27
27
|
const RESOLVED_WORKFLOW = inputs.resolved_workflow || null;
|
|
28
28
|
const WORKFLOW_WAS_NULL = inputs.workflow_was_null === true;
|
|
29
|
-
|
|
29
|
+
|
|
30
|
+
// Visual verdict protocol availability — the workflow parks for parent-run
|
|
31
|
+
// baseline capture ONLY when the protocol is fully shipped. The protocol
|
|
32
|
+
// requires docs/visual-verdict.md in the release AND the parent-side
|
|
33
|
+
// capture tooling (task b309a97d, "QA owns the visual verdict"). Until both
|
|
34
|
+
// exist, the park would deadlock waiting for a parent who cannot fulfill
|
|
35
|
+
// it.
|
|
36
|
+
// Effective value for this run, resolved by the dispatcher from the
|
|
37
|
+
// project's visual_protocol setting (null=inherits crew default=off).
|
|
38
|
+
// Manual launches without the arg default to off (previous behavior).
|
|
39
|
+
var VISUAL_PROTOCOL_AVAILABLE = inputs.visual_protocol === true;
|
|
30
40
|
|
|
31
41
|
// Config from args — backward-compatible fallbacks for manual launches
|
|
32
|
-
const DASHBOARD_SLUG = inputs.dashboardSlug || "orchestra-dashboard";
|
|
33
42
|
const crewHome = inputs.crewHome || "~/workspace/.jarvis";
|
|
43
|
+
// Crew API: the workflow calls the crew-owned CLI, not the dashboard.
|
|
44
|
+
// The CLI implements the API.md contract against $CREW_HOME/crew-state.db.
|
|
45
|
+
const CREW_API = crewHome + "/current/lib/crew-api.js";
|
|
46
|
+
// Build a shell command invoking the CLI. Args are JSON-encoded and
|
|
47
|
+
// single-quote-wrapped for safe shell passing. The agent runs this and
|
|
48
|
+
// returns the stdout verbatim (the CLI emits JSON on stdout).
|
|
49
|
+
function crewCmd(command, args) {
|
|
50
|
+
var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
|
|
51
|
+
return "node " + CREW_API + " --crew-home " + crewHome + " " + command + " --json '" + json + "'";
|
|
52
|
+
}
|
|
34
53
|
const ORCH_PATH = crewHome + "/.orchestration";
|
|
35
54
|
// Pin lifecycle scripts to this run
|
|
36
55
|
const LIFECYCLE_SRC = crewHome + "/lib/worktree-lifecycle.sh";
|
|
37
56
|
const MERGE_LOCK_SRC = crewHome + "/lib/merge-lock.sh";
|
|
38
|
-
const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1)
|
|
57
|
+
const RUN_LIB = crewHome + "/.pins/" + taskId; // persistent disk, NOT /tmp (tmpfs wiped by cell reboots — canary b5efd1b1)
|
|
39
58
|
const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
|
|
40
59
|
const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
|
|
41
60
|
const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
|
|
42
61
|
const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
|
|
43
|
-
const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
|
|
44
|
-
const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
|
|
45
62
|
// The four basenames the pin step must materialize — asserted mechanically
|
|
46
63
|
// by workflow code from the verbatim listing, never from agent prose.
|
|
47
|
-
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM
|
|
64
|
+
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM].map(function (p) { return p.split("/").pop(); });
|
|
48
65
|
|
|
49
66
|
// Project config — passed by dispatcher, falls back to dashboard defaults
|
|
50
67
|
const projectConfig = inputs.project_config || {};
|
|
51
68
|
// Project this run was dispatched for — the mid-run project-change guard
|
|
52
69
|
// compares the task's live project against this on every phase boundary.
|
|
53
70
|
const LAUNCH_PROJECT_ID = inputs.project_id || "";
|
|
54
|
-
|
|
71
|
+
// Fail closed on a missing repo_path — never silently fall back to
|
|
72
|
+
// another checkout (the canary's wrong-repo Build, 2026-09-11: prepare
|
|
73
|
+
// failed in the npm package and the builder freelanced into a dashboard
|
|
74
|
+
// clone). The dispatcher skips unconfigured projects; this is the
|
|
75
|
+
// backstop for direct launches.
|
|
76
|
+
const REPO_PATH = projectConfig.repo_path || "";
|
|
77
|
+
if (!REPO_PATH) {
|
|
78
|
+
throw new Error("Project '" + (inputs.project_id || "unknown") + "' has no repo_path configured — set it via updateproject before dispatching tasks.");
|
|
79
|
+
}
|
|
55
80
|
|
|
56
81
|
// Worktree layout: the task's worktree lives at
|
|
57
82
|
// <repo>/.worktrees/<task_id> on branch task/<task_id>. The lifecycle
|
|
@@ -59,7 +84,7 @@ const REPO_PATH = projectConfig.repo_path || "~/workspace/ts-spaces/orchestra-da
|
|
|
59
84
|
// registry at <repo>/.worktrees/.registry/<task_id> is the source of
|
|
60
85
|
// truth for task→branch/path.
|
|
61
86
|
// Env prefix baked into every lifecycle invocation the agents run.
|
|
62
|
-
const LIFECYCLE_ENV = "CREW_REPO=" + REPO_PATH + " ";
|
|
87
|
+
const LIFECYCLE_ENV = "CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " ";
|
|
63
88
|
// The task branch is always task/<task_id>.
|
|
64
89
|
const TASK_BRANCH = "task/" + taskId;
|
|
65
90
|
// Where the agent works.
|
|
@@ -68,7 +93,7 @@ const WORKTREE_HINT = REPO_PATH + "/.worktrees/" + taskId;
|
|
|
68
93
|
const WORKTREE_PRESERVED_HINT = ".worktrees/" + taskId;
|
|
69
94
|
|
|
70
95
|
const PUBLISH_TYPE = projectConfig.deploy_type || "";
|
|
71
|
-
const PUBLISH_SLUG = projectConfig.deploy_slug ||
|
|
96
|
+
const PUBLISH_SLUG = projectConfig.deploy_slug || "";
|
|
72
97
|
const PROJECT_DESC = projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)";
|
|
73
98
|
const RELEASE_SCRIPT = crewHome + "/crew-release.sh";
|
|
74
99
|
|
|
@@ -190,7 +215,7 @@ function attemptKey(base, reworkCount) {
|
|
|
190
215
|
function pinLifecycle(key) {
|
|
191
216
|
return agent(
|
|
192
217
|
"Snapshot lifecycle scripts for version pinning.\n" +
|
|
193
|
-
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " &&
|
|
218
|
+
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
194
219
|
"Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
|
|
195
220
|
{ key: key, label: "Pinning lifecycle scripts",
|
|
196
221
|
schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
|
|
@@ -202,19 +227,65 @@ function pinLifecycle(key) {
|
|
|
202
227
|
function parsePinListing(result) {
|
|
203
228
|
return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
204
229
|
}
|
|
230
|
+
// TOOL_CHECK_PREAMBLE - every work agent runs this first. The artifact tool
|
|
231
|
+
// namespace is deferred for workflow children: present but invisible until
|
|
232
|
+
// the child loads it via tool_search.load_tool_namespace (bug 3472bf36 root
|
|
233
|
+
// cause, verified 2026-09-11 by direct probe: 5/5 children self-loaded it;
|
|
234
|
+
// the "non-deterministic platform flake" was children never being told to
|
|
235
|
+
// load it). The two signal lines are the ONLY machine-read tool-availability
|
|
236
|
+
// evidence - the workflow never guesses from English prose.
|
|
237
|
+
// Byte-identical across standard/bugfix/chore/docs - pinned by
|
|
238
|
+
// tests/artifact-tools.test.js.
|
|
239
|
+
var TOOL_CHECK_PREAMBLE =
|
|
240
|
+
"TOOL CHECK (do this first, before any other work):\n" +
|
|
241
|
+
"1. Call tool_search.load_tool_namespace with paths [\"artifact\"].\n" +
|
|
242
|
+
"2. Write exactly one line: artifact_tools: ok - or artifact_tools: missing if the call failed or the tool does not exist.\n" +
|
|
243
|
+
"3. Run the shell command: echo tool-probe-ok - then write exactly one line: shell_transport: ok - or shell_transport: unavailable if you cannot run shell commands.\n" +
|
|
244
|
+
"Then do the assignment below.\n\n";
|
|
245
|
+
// ARTIFACT_LOAD_PREAMBLE - the one-line load instruction that precedes every
|
|
246
|
+
// special-purpose prompt calling artifact tools (rebuild trigger, provenance
|
|
247
|
+
// stamp, publish verification). The artifact tool namespace is deferred for
|
|
248
|
+
// workflow children: present but invisible until the child loads it via
|
|
249
|
+
// tool_search.load_tool_namespace (bug 3472bf36 root cause). Byte-identical
|
|
250
|
+
// across standard/bugfix/chore - pinned by tests/artifact-tools.test.js.
|
|
251
|
+
var ARTIFACT_LOAD_PREAMBLE =
|
|
252
|
+
"First call tool_search.load_tool_namespace with paths [\"artifact\"] - the artifact tool namespace is deferred and invisible until loaded.\n";
|
|
205
253
|
function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
|
|
206
|
-
// reason: "discarded" (the runtime threw the output away
|
|
207
|
-
// machine-read)
|
|
208
|
-
// nothing usable)
|
|
209
|
-
//
|
|
254
|
+
// reason: "discarded" (the runtime threw the output away - it could not be
|
|
255
|
+
// machine-read), "empty" (agent() returned without throwing but produced
|
|
256
|
+
// nothing usable), "no-tools" (the worker's TOOL CHECK reported
|
|
257
|
+
// artifact_tools: missing), or "no-transport" (the worker's TOOL CHECK
|
|
258
|
+
// reported shell_transport: unavailable).
|
|
259
|
+
// The trailer tells the retry what to expect, not just to try again.
|
|
210
260
|
var why = reason === "empty"
|
|
211
261
|
? "your previous attempt returned no usable output"
|
|
262
|
+
: reason === "no-tools"
|
|
263
|
+
? "your previous attempt's TOOL CHECK reported artifact_tools: missing (this is a fresh launch, so run the TOOL CHECK's load step again before the work)"
|
|
264
|
+
: reason === "no-transport"
|
|
265
|
+
? "your previous attempt's TOOL CHECK reported shell_transport: unavailable (this is a fresh launch, so run the TOOL CHECK's shell probe again before the work)"
|
|
212
266
|
: "your previous attempt's output could not be machine-read as JSON and was discarded";
|
|
213
267
|
return "\n\nTRANSPORT RETRY (attempt " + attempt + " of 2): " + why + ". " +
|
|
214
|
-
"First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task)
|
|
268
|
+
"First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) - " +
|
|
215
269
|
"if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
|
|
216
270
|
"Then return your report as JSON in exactly the shape specified above.";
|
|
217
271
|
}
|
|
272
|
+
// parseToolSignals - bug 3472bf36. The work agent's TOOL CHECK emits two
|
|
273
|
+
// exact signal lines: artifact_tools: ok|missing and
|
|
274
|
+
// shell_transport: ok|unavailable. The workflow reads ONLY these lines.
|
|
275
|
+
// It never guesses tool availability from English prose: the old regex
|
|
276
|
+
// misclassified ordinary inability-prose (e.g. a Map agent describing what
|
|
277
|
+
// it could not inspect) and burned all three attempts on useless retries.
|
|
278
|
+
// Byte-identical across standard/bugfix/chore/docs - pinned by
|
|
279
|
+
// tests/artifact-tools.test.js.
|
|
280
|
+
function parseToolSignals(text) {
|
|
281
|
+
var t = String(text || "");
|
|
282
|
+
var out = { artifactTools: "unknown", shellTransport: "unknown" };
|
|
283
|
+
var am = t.match(/^artifact_tools:\s*(ok|missing)\s*$/m);
|
|
284
|
+
if (am) out.artifactTools = am[1];
|
|
285
|
+
var sm = t.match(/^shell_transport:\s*(ok|unavailable)\s*$/m);
|
|
286
|
+
if (sm) out.shellTransport = sm[1];
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
218
289
|
function describeWorkAgentFailure(stepName, identity, attempts) {
|
|
219
290
|
// Honest classification of a work-agent call that yielded no usable
|
|
220
291
|
// report, with the per-attempt evidence preserved in the session notes.
|
|
@@ -268,6 +339,259 @@ function extractReleaseDecision(workerText) {
|
|
|
268
339
|
if (rel === "yes" && !b) return null;
|
|
269
340
|
return { release: rel, version_bump: b ? b[1].toLowerCase() : null };
|
|
270
341
|
}
|
|
342
|
+
// Merge-record helpers (merge-lease fix, 2026-09-12): the integrate step
|
|
343
|
+
// records the exact merged commit in $CREW_HOME/.merge-records/<task_id>,
|
|
344
|
+
// so a re-dispatched run can verify a landed merge and publish it even
|
|
345
|
+
// after the branch was reclaimed under an expired lease. All pure — no I/O,
|
|
346
|
+
// no clock. Byte-identical in standard.js, bugfix.js, chore.js.
|
|
347
|
+
function parseMergeRecord(text) {
|
|
348
|
+
var t = (text || "").trim();
|
|
349
|
+
if (!t || t === "NO_RECORD") return null;
|
|
350
|
+
// Append-only record; last occurrence of each key wins.
|
|
351
|
+
function lastVal(prefix) {
|
|
352
|
+
var v = null, found = false;
|
|
353
|
+
var lines = t.split("\n");
|
|
354
|
+
for (var i = 0; i < lines.length; i++) {
|
|
355
|
+
var line = lines[i].trim();
|
|
356
|
+
if (line.indexOf(prefix) === 0) { v = line.slice(prefix.length).trim(); found = true; }
|
|
357
|
+
}
|
|
358
|
+
return found ? v : null;
|
|
359
|
+
}
|
|
360
|
+
var commit = lastVal("merge_commit=");
|
|
361
|
+
var release = lastVal("release=");
|
|
362
|
+
var bump = lastVal("version_bump=");
|
|
363
|
+
var malformed = false;
|
|
364
|
+
if (commit === null || !/^[0-9a-f]{40}$/.test(commit)) malformed = true;
|
|
365
|
+
if (release !== null && !/^(yes|no|unknown)$/.test(release)) malformed = true;
|
|
366
|
+
if (bump !== null && !/^(patch|minor|major|none)$/.test(bump)) malformed = true;
|
|
367
|
+
if (malformed) return { malformed: true };
|
|
368
|
+
return { merge_commit: commit, merged_at: lastVal("merged_at="), release: release, version_bump: bump };
|
|
369
|
+
}
|
|
370
|
+
// Publish lock decision table: what the merge record + lock state say about
|
|
371
|
+
// the publish window. Pure — pinned by tests/merge-record.test.js.
|
|
372
|
+
function decidePublishLockPath(o) {
|
|
373
|
+
var rec = o.rec, ancestor = !!o.ancestor, lockHeld = !!o.lockHeld;
|
|
374
|
+
if (rec === null) return lockHeld ? "refresh" : "skip";
|
|
375
|
+
if (rec.malformed) return "park";
|
|
376
|
+
if (!ancestor) return "park";
|
|
377
|
+
return lockHeld ? "refresh" : "reacquire";
|
|
378
|
+
}
|
|
379
|
+
// Integrate retry decision: a landed merge must route to Publish, not
|
|
380
|
+
// re-integrate (the branch may be gone — reclaimed under an expired lease).
|
|
381
|
+
// Pure — pinned by tests/merge-record.test.js.
|
|
382
|
+
function decideIntegrateRetry(o) {
|
|
383
|
+
var rec = o.rec, ancestor = !!o.ancestor;
|
|
384
|
+
if (rec === null) return "proceed";
|
|
385
|
+
if (rec.malformed) return "park";
|
|
386
|
+
return ancestor ? "skip-to-publish" : "proceed";
|
|
387
|
+
}
|
|
388
|
+
// On a dispatcher retry resumed at Integrate, the run-local releaseDecision
|
|
389
|
+
// is null (Build doesn't re-run). Hydrate it from the merge record so the
|
|
390
|
+
// npm Publish gate doesn't park a publishable merge. Pure — pinned by
|
|
391
|
+
// tests/merge-record.test.js.
|
|
392
|
+
function hydrateReleaseDecision(rec) {
|
|
393
|
+
if (rec && (rec.release === "yes" || rec.release === "no")) {
|
|
394
|
+
return { release: rec.release, version_bump: rec.version_bump === "none" ? null : rec.version_bump };
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Publish diff transport: parse a unified diff into per-file {path, added,
|
|
400
|
+
// removed} line lists. Pure function — no I/O, no clock. Used by Publish to
|
|
401
|
+
// verify the artifact builder applied the carried change (canary run 4,
|
|
402
|
+
// 2026-09-11: the builder's source tree was stale and disconnected from the
|
|
403
|
+
// crew's repo; "rebuild from current source" rebuilt stale code and the
|
|
404
|
+
// workflow stamped the new commit hash on it — provenance fiction).
|
|
405
|
+
function parseUnifiedDiff(diffText) {
|
|
406
|
+
var files = [];
|
|
407
|
+
var current = null;
|
|
408
|
+
var lines = (diffText || "").split("\n");
|
|
409
|
+
for (var i = 0; i < lines.length; i++) {
|
|
410
|
+
var line = lines[i];
|
|
411
|
+
var m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
|
|
412
|
+
if (m) {
|
|
413
|
+
current = { path: m[2], added: [], removed: [] };
|
|
414
|
+
files.push(current);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!current) continue;
|
|
418
|
+
if (/^--- /.test(line) || /^\+\+\+ /.test(line)) continue;
|
|
419
|
+
if (/^@@ /.test(line)) continue;
|
|
420
|
+
if (line.charAt(0) === "+") {
|
|
421
|
+
current.added.push(line.slice(1));
|
|
422
|
+
} else if (line.charAt(0) === "-") {
|
|
423
|
+
current.removed.push(line.slice(1));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return files;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Compare the artifact builder's reported applied-changes against the diff's
|
|
430
|
+
// expected changes. Every added/removed line must match exactly per path,
|
|
431
|
+
// and the file counts must match — the builder applies exactly the carried
|
|
432
|
+
// change, nothing more, nothing less. Pure function — no I/O, no clock.
|
|
433
|
+
// OBSERVATION INPUT ONLY (2026-09-12, task 23ca8f3f): the applied report
|
|
434
|
+
// has a demonstrated false-negative mode (applied:[] for a diff the builder
|
|
435
|
+
// had actually applied), and it is derived from the carried diff, so a match
|
|
436
|
+
// certifies nothing either. A mismatch is logged as observation; it never
|
|
437
|
+
// parks and never blocks the stamp. The verification is the independent
|
|
438
|
+
// read-back (docs/publish-verification.md).
|
|
439
|
+
function verifyAppliedChanges(expected, applied) {
|
|
440
|
+
if (!Array.isArray(applied)) {
|
|
441
|
+
return { ok: false, reason: "builder returned no applied-changes list" };
|
|
442
|
+
}
|
|
443
|
+
function sorted(a) { return (a || []).slice().sort(); }
|
|
444
|
+
function eq(a, b) {
|
|
445
|
+
a = sorted(a); b = sorted(b);
|
|
446
|
+
if (a.length !== b.length) return false;
|
|
447
|
+
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
for (var i = 0; i < expected.length; i++) {
|
|
451
|
+
var exp = expected[i];
|
|
452
|
+
var got = null;
|
|
453
|
+
for (var j = 0; j < applied.length; j++) {
|
|
454
|
+
if (applied[j] && applied[j].path === exp.path) { got = applied[j]; break; }
|
|
455
|
+
}
|
|
456
|
+
if (!got) {
|
|
457
|
+
return { ok: false, reason: "builder did not report changing '" + exp.path + "'" };
|
|
458
|
+
}
|
|
459
|
+
if (!eq(exp.added, got.added)) {
|
|
460
|
+
return { ok: false, reason: "added lines for '" + exp.path + "' do not match the carried diff" };
|
|
461
|
+
}
|
|
462
|
+
if (!eq(exp.removed, got.removed)) {
|
|
463
|
+
return { ok: false, reason: "removed lines for '" + exp.path + "' do not match the carried diff" };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (applied.length !== expected.length) {
|
|
467
|
+
return { ok: false, reason: "builder reported changing " + applied.length + " file(s), diff carries " + expected.length };
|
|
468
|
+
}
|
|
469
|
+
return { ok: true };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Publish read-back request builder: the verbatim_request the workflow hands
|
|
473
|
+
// to artifact_inspect (via a child) after the artifact build lands. Pure
|
|
474
|
+
// function — no I/O, no clock. The request carries the merged diff as the
|
|
475
|
+
// expected change and asks for an independent read of the artifact's actual
|
|
476
|
+
// source: for each file, the exact current text of the changed regions plus
|
|
477
|
+
// a per-line present/absent finding. The parent (docs/publish-verification.md)
|
|
478
|
+
// compares these findings against the diff mechanically and stamps provenance
|
|
479
|
+
// only on a match. This breaks the circularity that hollowed canary run 8
|
|
480
|
+
// (2026-09-11): verifyAppliedChanges compares the builder's applied-report
|
|
481
|
+
// against the diff the report was derived from — a fabricated report passes
|
|
482
|
+
// by construction. Independent read-back cannot be fabricated from the diff;
|
|
483
|
+
// it must match the artifact's real content.
|
|
484
|
+
function buildPublishReadbackRequest(taskId, commit, diff, buildAgentId) {
|
|
485
|
+
// Build-ID correlation (2026-09-12): buildAgentId is the build.agent_id the
|
|
486
|
+
// workflow observed for the publish attempt (the artifact system's durable
|
|
487
|
+
// build identifier). The read-back request carries it so the parent can
|
|
488
|
+
// prove the read-back inspected the live build of THIS attempt — not a
|
|
489
|
+
// different build's output. Null/empty means the edit was accepted but
|
|
490
|
+
// never correlated to a builder run. Pure function of inputs — no I/O,
|
|
491
|
+
// no clock.
|
|
492
|
+
var buildIdLine = (typeof buildAgentId === "string" && buildAgentId.length > 0)
|
|
493
|
+
? "Expected builder build agent_id: " + buildAgentId + " (the artifact system's in-flight correlation ID for this publish attempt — not a durable post-completion identifier).\n"
|
|
494
|
+
: "No build agent_id was observed for this publish attempt (the edit was accepted but never correlated to a builder run) — say so explicitly in your report.\n";
|
|
495
|
+
return (
|
|
496
|
+
"Publish content read-back for task " + taskId + ", merge commit " + commit + ".\n" +
|
|
497
|
+
"The unified diff below was supposed to be applied to this artifact's source tree and deployed. Do NOT modify anything.\n" +
|
|
498
|
+
"Do NOT rely on the builder's applied-changes report — it is derived from this same diff, so it cannot confirm the content. Read the artifact's CURRENT source directly.\n" +
|
|
499
|
+
"\n" +
|
|
500
|
+
buildIdLine +
|
|
501
|
+
"Report the live build's agent_id as seen in artifact_status (or state explicitly that no build/agent_id is visible). If an expected agent_id is given above and the live one differs, say so exactly — the read-back may be inspecting a different build's output.\n" +
|
|
502
|
+
"\n" +
|
|
503
|
+
"UNIFIED DIFF (expected change):\n" +
|
|
504
|
+
"```diff\n" + diff + "\n```\n" +
|
|
505
|
+
"\n" +
|
|
506
|
+
"For each file in the diff:\n" +
|
|
507
|
+
"1. Read the file's CURRENT content in the artifact source tree.\n" +
|
|
508
|
+
"2. Quote the exact current text of the regions around the changed lines.\n" +
|
|
509
|
+
"3. For every added (+) line in the diff, state whether that exact line is PRESENT in the current source.\n" +
|
|
510
|
+
"4. For every removed (-) line in the diff, state whether that exact line is ABSENT from the current source.\n" +
|
|
511
|
+
"5. Report build/deploy health and the console error count.\n" +
|
|
512
|
+
"\n" +
|
|
513
|
+
"Return the per-file present/absent findings with the quoted observed lines. Do not modify anything.\n" +
|
|
514
|
+
"This read-back feeds the parent content-verification protocol (docs/publish-verification.md): the parent stamps provenance only when every added line is present and every removed line is absent."
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Pre-publish base observation (diagnostic, 2026-09-12): instruction fragment
|
|
519
|
+
// for the builder's edit request, asking it to report the sha256 of each
|
|
520
|
+
// touched file's CURRENT content BEFORE applying the diff. Pure function —
|
|
521
|
+
// no I/O, no clock.
|
|
522
|
+
//
|
|
523
|
+
// Why: the builder applies the carried diff to its own source tree, whose
|
|
524
|
+
// base state is unrecorded. The post-hoc read-back only checks the changed
|
|
525
|
+
// regions AFTER the edit; it cannot tell us what base the diff landed on.
|
|
526
|
+
// If the tree was dirty or drifted before the edit, the read-back still
|
|
527
|
+
// passes (the diff's lines are present) while the artifact silently carries
|
|
528
|
+
// uncommitted content — the production validateRepoPath incident
|
|
529
|
+
// (2026-09-12), where the live artifact contained code absent from every git
|
|
530
|
+
// ref. These pre-hashes, compared against the workflow-computed expected
|
|
531
|
+
// base hashes (merge parent), reveal what the publish actually read.
|
|
532
|
+
// Observation only — the workflow logs mismatches but never parks on them.
|
|
533
|
+
function buildPreHashInstruction(files) {
|
|
534
|
+
var paths = files.map(function(f) { return f.path; }).join(", ");
|
|
535
|
+
return (
|
|
536
|
+
"- BEFORE applying anything, compute the sha256 hash of each file below as it CURRENTLY exists in your source tree (before your changes). Use the exact bytes of the current file content.\n" +
|
|
537
|
+
"- Report these hashes in the \"pre_hashes\" field of your return JSON, as { \"<path>\": \"<sha256 hex>\" }.\n" +
|
|
538
|
+
"- If a file does not exist in your tree, report its hash as the string \"MISSING\".\n" +
|
|
539
|
+
"- Do this BEFORE applying the diff — the hashes must reflect the pre-edit state.\n" +
|
|
540
|
+
" Files: " + paths + "\n"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Durable publish-attempt ledger (2026-09-12): every artifact publish
|
|
545
|
+
// attempt is recorded append-only at $CREW_HOME/.publish-ledger/<slug>.jsonl
|
|
546
|
+
// on persistent disk (NOT /tmp). The ledger is the correlation record for
|
|
547
|
+
// publish attempts whose outcome is UNKNOWN. When the rebuild trigger's
|
|
548
|
+
// child returns prose instead of JSON (structured-output failure), the edit
|
|
549
|
+
// may already have been accepted as pending_init — and artifact_status
|
|
550
|
+
// cannot see pending_init (diagnostic canary 2026-09-12: an edit accepted
|
|
551
|
+
// as pending_init was immediately followed by an all-false status check,
|
|
552
|
+
// and the old retry issued a DUPLICATE edit). "No build visible" is NOT
|
|
553
|
+
// evidence the edit did not go through, so the workflow never blind-retries
|
|
554
|
+
// on an unknown outcome: it records the attempt and parks fail-closed. A
|
|
555
|
+
// human or a later run correlates the accepted edit via the ledger (commit
|
|
556
|
+
// hash + attempt key + the artifact build's agent_id when one was observed)
|
|
557
|
+
// instead of guessing from a blind status poll.
|
|
558
|
+
// Best-effort observability: a failed write is logged loudly but never
|
|
559
|
+
// throws — the caller's park/proceed decision never depends on the ledger.
|
|
560
|
+
// Byte-identical across standard/bugfix/chore — pinned by
|
|
561
|
+
// tests/publish-ledger.test.js.
|
|
562
|
+
async function recordPublishLedger(entry, rework) {
|
|
563
|
+
try {
|
|
564
|
+
var ledgerDir = crewHome + "/.publish-ledger";
|
|
565
|
+
var line = JSON.stringify({
|
|
566
|
+
ts: "@LEDGER_TS@",
|
|
567
|
+
task_id: taskId,
|
|
568
|
+
workflow: RESOLVED_WORKFLOW || "unknown",
|
|
569
|
+
slug: PUBLISH_SLUG,
|
|
570
|
+
commit: entry.commit || null,
|
|
571
|
+
attempt: entry.attempt || null,
|
|
572
|
+
agent_id: entry.agent_id || null,
|
|
573
|
+
applied_report: entry.applied_report || null,
|
|
574
|
+
outcome: entry.outcome,
|
|
575
|
+
detail: entry.detail || ""
|
|
576
|
+
});
|
|
577
|
+
var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
|
|
578
|
+
var res = await agent(
|
|
579
|
+
"Append one line to the publish-attempt ledger (best-effort observability, not a gate).\n" +
|
|
580
|
+
"Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
|
|
581
|
+
" | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
|
|
582
|
+
"Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
|
|
583
|
+
{ key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
|
|
584
|
+
label: "Recording publish attempt in ledger",
|
|
585
|
+
schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
|
|
586
|
+
);
|
|
587
|
+
var ok = !!(res && res.result && res.result.indexOf("LEDGER_OK") !== -1);
|
|
588
|
+
log("Publish ledger: outcome '" + entry.outcome + "' for task " + taskId +
|
|
589
|
+
(ok ? " recorded." : " NOT confirmed (" + ((res && res.result) || "no output") + ")"));
|
|
590
|
+
} catch (e) {
|
|
591
|
+
log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
271
595
|
// Marker line preservation: machine-readable lines (repo_diff:, release:,
|
|
272
596
|
// version_bump:, VERDICT:, TARGET_VERSION=, published:) are extracted from
|
|
273
597
|
// the full worker report and appended after the summary slice, so a long
|
|
@@ -277,13 +601,37 @@ function extractMarkerLines(workerText) {
|
|
|
277
601
|
var markers = [];
|
|
278
602
|
for (var i = 0; i < lines.length; i++) {
|
|
279
603
|
var line = lines[i].trim();
|
|
280
|
-
if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:)/i.test(line)) {
|
|
604
|
+
if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:|worktree:)/i.test(line)) {
|
|
281
605
|
markers.push(line);
|
|
282
606
|
}
|
|
283
607
|
}
|
|
284
608
|
return markers.join("\n");
|
|
285
609
|
}
|
|
286
610
|
|
|
611
|
+
// Worktree confinement: the Build agent must declare the exact worktree
|
|
612
|
+
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
613
|
+
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
614
|
+
// reading agent prose. This closes the hole where a builder whose prepare
|
|
615
|
+
// failed freelanced into a different checkout (canary, 2026-09-11): the
|
|
616
|
+
// honest-but-confused case fails here, and a fabricated path is caught one
|
|
617
|
+
// phase later when Review's inspect finds no commits in the configured repo.
|
|
618
|
+
function extractWorktree(workerText) {
|
|
619
|
+
var lines = (workerText || "").split("\n");
|
|
620
|
+
var found = null;
|
|
621
|
+
for (var i = 0; i < lines.length; i++) {
|
|
622
|
+
var line = lines[i].trim();
|
|
623
|
+
var m = /^worktree:\s*(\S.*)$/i.exec(line);
|
|
624
|
+
if (m) found = m[1].trim();
|
|
625
|
+
}
|
|
626
|
+
if (!found) return { ok: false };
|
|
627
|
+
// Normalize trailing slashes: ".../<task_id>/" and ".../<task_id>"
|
|
628
|
+
// name the same directory. Compare locations, not spellings — a
|
|
629
|
+
// builder that emits the trailing slash still worked in the right
|
|
630
|
+
// place. (Canary 2026-09-11: an exact comparison rejected a correct
|
|
631
|
+
// declaration over one trailing slash.)
|
|
632
|
+
return { ok: true, path: found.replace(/\/+$/, "") };
|
|
633
|
+
}
|
|
634
|
+
|
|
287
635
|
// Visual verdict shared functions: byte-identical across standard.js,
|
|
288
636
|
// bugfix.js, and chore.js (pinned by tests/visual-verdict.test.js — same
|
|
289
637
|
// contract as buildTransportRetryTrailer).
|
|
@@ -332,8 +680,8 @@ async function resolveExperiential() {
|
|
|
332
680
|
var expCheck = null;
|
|
333
681
|
try {
|
|
334
682
|
expCheck = await agent(
|
|
335
|
-
"Find this task's Triage step session notes from the
|
|
336
|
-
"
|
|
683
|
+
"Find this task's Triage step session notes from the crew API.\n" +
|
|
684
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
337
685
|
"Find the session with task_id \"" + taskId + "\" and step \"Triage\" (status completed) in the returned sessions array and read its notes field.\n" +
|
|
338
686
|
"Return JSON { \"experiential_line\": \"<the exact text of the experiential: marker line from the notes, or empty string if absent>\" } and nothing else.",
|
|
339
687
|
{
|
|
@@ -355,10 +703,12 @@ async function resolveExperiential() {
|
|
|
355
703
|
}
|
|
356
704
|
// Baseline evidence status: reads the task's note events for the exact
|
|
357
705
|
// protocol prefixes (explicit state, never English matching). Returns
|
|
358
|
-
// { baseline_found, baseline_kind, baseline_refs, requested_count }.
|
|
706
|
+
// { baseline_found, baseline_kind, baseline_refs, requested_count, evidence_count }.
|
|
359
707
|
// found = any note starting exactly "baseline: captured" or "baseline:
|
|
360
708
|
// none" (kind/refs come from the LATEST such message); requested_count =
|
|
361
|
-
// the number of notes starting exactly "baseline: requested"
|
|
709
|
+
// the number of notes starting exactly "baseline: requested";
|
|
710
|
+
// evidence_count = the number of notes starting exactly "baseline: captured" or
|
|
711
|
+
// "baseline: none" (the per-attempt evidence chain; state-derived, no wall clock). Each call
|
|
362
712
|
// uses a fresh key: the evidence changes between calls (the parent logs
|
|
363
713
|
// the capture while this run is parked), so a cached replay would lie.
|
|
364
714
|
let baselineStatusCallCount = 0;
|
|
@@ -366,10 +716,10 @@ async function baselineStatus() {
|
|
|
366
716
|
baselineStatusCallCount++;
|
|
367
717
|
try {
|
|
368
718
|
var ev = await agent(
|
|
369
|
-
"Read this task's note events from the
|
|
370
|
-
"
|
|
719
|
+
"Read this task's note events from the crew API.\n" +
|
|
720
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId }) + "\n" +
|
|
371
721
|
"Consider only events with type \"note\". For each message, check whether it starts exactly with \"baseline: captured\", \"baseline: none\", or \"baseline: requested\" (exact prefix, case-sensitive).\n" +
|
|
372
|
-
"Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\"> } and nothing else.",
|
|
722
|
+
"Return JSON { \"baseline_found\": <true if any message starts exactly with \"baseline: captured\" or \"baseline: none\">, \"baseline_kind\": \"<\"captured\" or \"none\", from the LATEST such message, or empty string if none>\", \"baseline_refs\": \"<the text after the prefix in that latest message, or empty string>\", \"requested_count\": <count of messages starting exactly with \"baseline: requested\">, \"evidence_count\": <count of messages starting exactly with \"baseline: captured\" or \"baseline: none\"> } and nothing else.",
|
|
373
723
|
{
|
|
374
724
|
key: "baseline-status-" + taskId + "-" + baselineStatusCallCount,
|
|
375
725
|
label: "Reading baseline evidence status",
|
|
@@ -379,9 +729,10 @@ async function baselineStatus() {
|
|
|
379
729
|
baseline_found: { type: "boolean" },
|
|
380
730
|
baseline_kind: { type: "string" },
|
|
381
731
|
baseline_refs: { type: "string" },
|
|
382
|
-
requested_count: { type: "number" }
|
|
732
|
+
requested_count: { type: "number" },
|
|
733
|
+
evidence_count: { type: "number" }
|
|
383
734
|
},
|
|
384
|
-
required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count"]
|
|
735
|
+
required: ["baseline_found", "baseline_kind", "baseline_refs", "requested_count", "evidence_count"]
|
|
385
736
|
}
|
|
386
737
|
}
|
|
387
738
|
);
|
|
@@ -389,11 +740,12 @@ async function baselineStatus() {
|
|
|
389
740
|
baseline_found: !!(ev && ev.baseline_found),
|
|
390
741
|
baseline_kind: (ev && ev.baseline_kind) || "",
|
|
391
742
|
baseline_refs: (ev && ev.baseline_refs) || "",
|
|
392
|
-
requested_count: (ev && ev.requested_count) || 0
|
|
743
|
+
requested_count: (ev && ev.requested_count) || 0,
|
|
744
|
+
evidence_count: (ev && ev.evidence_count) || 0
|
|
393
745
|
};
|
|
394
746
|
} catch (e) {
|
|
395
747
|
log("baselineStatus: agent call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
|
|
396
|
-
return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0 };
|
|
748
|
+
return { baseline_found: false, baseline_kind: "", baseline_refs: "", requested_count: 0, evidence_count: 0 };
|
|
397
749
|
}
|
|
398
750
|
}
|
|
399
751
|
|
|
@@ -456,21 +808,44 @@ function releaseDecisionText() {
|
|
|
456
808
|
// envelope the launcher sees. If the park call itself fails, the run
|
|
457
809
|
// reports "failed" (retryable) so the next tick re-attempts the park —
|
|
458
810
|
// a lost park is never reported as parked.
|
|
811
|
+
// Terminal cleanup: the run's last act at every park/fail boundary. A run
|
|
812
|
+
// that parks or fails must not leak its worktree, branch, or merge lock.
|
|
813
|
+
// The lifecycle's terminal-cleanup releases the lock unconditionally and
|
|
814
|
+
// reclaims the worktree+branch ONLY when the task branch is fully merged
|
|
815
|
+
// into main (then it is redundant); unmerged work is preserved for the
|
|
816
|
+
// human by design. Fire-and-forget with one bounded retry — the merge-lock
|
|
817
|
+
// lease expiry and the orphan sweep are the backstop for a dead transport.
|
|
818
|
+
async function terminalCleanup() {
|
|
819
|
+
for (var attempt = 1; attempt <= 2; attempt++) {
|
|
820
|
+
try {
|
|
821
|
+
await agent(
|
|
822
|
+
"Run in shell and return the stdout verbatim:\n" + LIFECYCLE_ENV + " terminal-cleanup " + taskId,
|
|
823
|
+
{ key: "terminal-cleanup" + (attempt > 1 ? "-retry" : ""),
|
|
824
|
+
label: "Terminal cleanup (merged-branch reclamation)" + (attempt > 1 ? " (retry)" : "") }
|
|
825
|
+
);
|
|
826
|
+
return;
|
|
827
|
+
} catch (cleanupErr) {
|
|
828
|
+
log("Terminal cleanup attempt " + attempt + " failed for task " + taskId + ": " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
log("Terminal cleanup exhausted for task " + taskId + " — merge-lock lease expiry and orphan sweep are the backstop");
|
|
832
|
+
}
|
|
459
833
|
async function parkTask(reason) {
|
|
460
834
|
log("Parking task " + taskId + " for human attention: " + reason);
|
|
461
835
|
var parkMessage = ("Parked: " + reason).slice(0, 1000);
|
|
462
836
|
try {
|
|
463
837
|
await agent(
|
|
464
838
|
"Park this task for human attention.\n" +
|
|
465
|
-
"
|
|
466
|
-
JSON.stringify({ task_id: taskId, message: parkMessage }) + ".\n" +
|
|
839
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("park-task", { task_id: taskId, message: parkMessage }) + "\n" +
|
|
467
840
|
"The parked state is the human-attention signal — the dispatcher skips parked tasks.",
|
|
468
|
-
{ key: "park-task", label: "Parking task for human attention"
|
|
841
|
+
{ key: "park-task", label: "Parking task for human attention" }
|
|
469
842
|
);
|
|
470
843
|
} catch (parkErr) {
|
|
471
844
|
log("PARK FAILED for task " + taskId + ": " + (parkErr && parkErr.message ? parkErr.message : parkErr) + " — park did not land, reporting failed so the next tick retries");
|
|
845
|
+
await terminalCleanup();
|
|
472
846
|
return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
|
|
473
847
|
}
|
|
848
|
+
await terminalCleanup();
|
|
474
849
|
return { status: "parked", task_id: taskId, reason: reason };
|
|
475
850
|
}
|
|
476
851
|
let i = startStepIndex;
|
|
@@ -508,8 +883,8 @@ while (i < STEPS.length) {
|
|
|
508
883
|
// re-launches the step with the new project's config.
|
|
509
884
|
if (LAUNCH_PROJECT_ID) {
|
|
510
885
|
const projectCheck = await agent(
|
|
511
|
-
"Read this task's current project from the
|
|
512
|
-
"
|
|
886
|
+
"Read this task's current project from the crew API.\n" +
|
|
887
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
513
888
|
"Find the task with id \"" + taskId + "\" in the returned tasks array.\n" +
|
|
514
889
|
"Return exactly { \"project\": \"<the task's project field, or empty string if absent>\" } and nothing else.",
|
|
515
890
|
{
|
|
@@ -524,14 +899,15 @@ while (i < STEPS.length) {
|
|
|
524
899
|
log(abortMessage);
|
|
525
900
|
await agent(
|
|
526
901
|
"Abort the stale run and remove its worktree from the old project's repo.\n" +
|
|
527
|
-
"
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
902
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
903
|
+
task_id: taskId,
|
|
904
|
+
session: { task_id: taskId, identity: step.identity, step: REWORK_STEP, status: "failed",
|
|
905
|
+
notes: abortMessage + " Rebuild from the Map session notes in the task's event history." },
|
|
906
|
+
event: { task_id: taskId, type: "failed", identity: step.identity, message: abortMessage }
|
|
907
|
+
}) + "\n" +
|
|
532
908
|
"Then run: "+ LIFECYCLE_ENV + " cleanup " + taskId + "\n" +
|
|
533
909
|
"The cleanup output should contain CLEANUP.",
|
|
534
|
-
{ key: "abort-project-change", label: "Aborting stale run (project changed)"
|
|
910
|
+
{ key: "abort-project-change", label: "Aborting stale run (project changed)" }
|
|
535
911
|
);
|
|
536
912
|
return { status: "failed", task_id: taskId, reason: abortMessage };
|
|
537
913
|
}
|
|
@@ -572,7 +948,7 @@ while (i < STEPS.length) {
|
|
|
572
948
|
"Release the merge lock and clean up without publishing.\n" +
|
|
573
949
|
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
574
950
|
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
575
|
-
{ key: attemptKey("publish-skip-cleanup", reworkCount), label: "Skipping Publish (no target)"
|
|
951
|
+
{ key: attemptKey("publish-skip-cleanup", reworkCount), label: "Skipping Publish (no target)" }
|
|
576
952
|
);
|
|
577
953
|
i++;
|
|
578
954
|
continue;
|
|
@@ -597,7 +973,7 @@ while (i < STEPS.length) {
|
|
|
597
973
|
"Release the merge lock and clean up without publishing.\n" +
|
|
598
974
|
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
599
975
|
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
600
|
-
{ key: attemptKey("publish-skip-release-no", reworkCount), label: "Skipping Publish (release: no)"
|
|
976
|
+
{ key: attemptKey("publish-skip-release-no", reworkCount), label: "Skipping Publish (release: no)" }
|
|
601
977
|
);
|
|
602
978
|
i++;
|
|
603
979
|
continue;
|
|
@@ -635,11 +1011,12 @@ while (i < STEPS.length) {
|
|
|
635
1011
|
// claimed:false and this run stands down as a duplicate.
|
|
636
1012
|
let activeSessionId;
|
|
637
1013
|
if (isFirstClaim) {
|
|
1014
|
+
var firstClaimUpdateArgs = { id: taskId, state: "in_progress" };
|
|
1015
|
+
if (WORKFLOW_WAS_NULL && RESOLVED_WORKFLOW) firstClaimUpdateArgs.workflow = RESOLVED_WORKFLOW;
|
|
638
1016
|
const claimResult = await agent(
|
|
639
1017
|
"Claim this task for the " + step.name + " step.\n" +
|
|
640
|
-
"
|
|
641
|
-
"Then
|
|
642
|
-
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started\" }.\n" +
|
|
1018
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", firstClaimUpdateArgs) + "\n" +
|
|
1019
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" }) + "\n" +
|
|
643
1020
|
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
644
1021
|
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
645
1022
|
{
|
|
@@ -660,8 +1037,7 @@ while (i < STEPS.length) {
|
|
|
660
1037
|
} else {
|
|
661
1038
|
const claimResult = await agent(
|
|
662
1039
|
"Claim a session for this task step.\n" +
|
|
663
|
-
"
|
|
664
|
-
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started" + (reworkCount > 0 ? " (rework #" + reworkCount + ")" : "") + "\" }.\n" +
|
|
1040
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("claim-task", { task_id: taskId, identity: step.identity, step: step.name, notes: step.name + " step started" + (reworkCount > 0 ? " (rework #" + reworkCount + ")" : "") }) + "\n" +
|
|
665
1041
|
"Return the session_id from the response.",
|
|
666
1042
|
{
|
|
667
1043
|
key: "claim-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
@@ -698,25 +1074,40 @@ while (i < STEPS.length) {
|
|
|
698
1074
|
log("Capture skipped for task " + taskId + " — " + (capExp !== "yes" ? "not experiential" : "publish target is not artifact"));
|
|
699
1075
|
await agent(
|
|
700
1076
|
"Update the session and log the event.\n" +
|
|
701
|
-
"
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1077
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1078
|
+
task_id: taskId,
|
|
1079
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "Capture skipped — not an experiential artifact task" },
|
|
1080
|
+
event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
|
|
1081
|
+
}) + "\n",
|
|
1082
|
+
{ key: "record-Capture" + bounceSuffix, label: "Recording Capture result" }
|
|
706
1083
|
);
|
|
707
1084
|
i++;
|
|
708
1085
|
continue;
|
|
709
1086
|
}
|
|
710
1087
|
var capStatus = await baselineStatus();
|
|
711
|
-
|
|
712
|
-
|
|
1088
|
+
// Stale-decision guard: a "baseline: none (visual protocol unavailable)"
|
|
1089
|
+
// note is only durable while the protocol is unavailable. When
|
|
1090
|
+
// VISUAL_PROTOCOL_AVAILABLE is true, that old decision no longer
|
|
1091
|
+
// stands — fall through to the request path for a fresh capture
|
|
1092
|
+
// attempt. Exact-string trim comparison against the workflow's own
|
|
1093
|
+
// written message (explicit state, never English matching).
|
|
1094
|
+
var baselineLatestMessage = ("baseline: " + capStatus.baseline_kind + capStatus.baseline_refs).trim();
|
|
1095
|
+
var baselineStale = VISUAL_PROTOCOL_AVAILABLE && baselineLatestMessage === "baseline: none (visual protocol unavailable)";
|
|
1096
|
+
if (capStatus.baseline_found && !baselineStale) {
|
|
1097
|
+
var evidenceN = capStatus.evidence_count + 1;
|
|
1098
|
+
var carryNote = "baseline: " + capStatus.baseline_kind + " (#" + evidenceN + " carries forward prior:" + capStatus.baseline_refs + ")";
|
|
1099
|
+
log("Capture: baseline evidence already recorded for task " + taskId + " (" + capStatus.baseline_kind + ") — logging per-attempt carry-forward note (evidence #" + evidenceN + ")");
|
|
713
1100
|
await agent(
|
|
714
|
-
"
|
|
715
|
-
"
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
"
|
|
719
|
-
|
|
1101
|
+
"Log the per-attempt baseline carry-forward note, then record the phase.\n" +
|
|
1102
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", {
|
|
1103
|
+
task_id: taskId, type: "note", identity: step.identity, message: carryNote
|
|
1104
|
+
}) + "\n" +
|
|
1105
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1106
|
+
task_id: taskId,
|
|
1107
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "Baseline evidence already recorded: " + capStatus.baseline_kind + " " + capStatus.baseline_refs + " (evidence #" + evidenceN + ")" },
|
|
1108
|
+
event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
|
|
1109
|
+
}),
|
|
1110
|
+
{ key: "record-Capture" + bounceSuffix, label: "Recording Capture result" }
|
|
720
1111
|
);
|
|
721
1112
|
i++;
|
|
722
1113
|
continue;
|
|
@@ -726,13 +1117,13 @@ while (i < STEPS.length) {
|
|
|
726
1117
|
log("Capture: baseline capture unavailable after 2 requests for task " + taskId + " — recording baseline:none");
|
|
727
1118
|
await agent(
|
|
728
1119
|
"Record that no baseline was capturable.\n" +
|
|
729
|
-
"
|
|
730
|
-
"
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
{ key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none"
|
|
1120
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: none (capture unavailable after 2 attempts)" }) + "\n" +
|
|
1121
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1122
|
+
task_id: taskId,
|
|
1123
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "baseline: none — continuing without baseline comparison" },
|
|
1124
|
+
event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
|
|
1125
|
+
}),
|
|
1126
|
+
{ key: "record-Capture-none" + bounceSuffix, label: "Recording baseline: none" }
|
|
736
1127
|
);
|
|
737
1128
|
i++;
|
|
738
1129
|
continue;
|
|
@@ -740,12 +1131,26 @@ while (i < STEPS.length) {
|
|
|
740
1131
|
log("Capture: requesting baseline capture (attempt " + attemptN + ") for task " + taskId);
|
|
741
1132
|
await agent(
|
|
742
1133
|
"Request the baseline capture and record the request.\n" +
|
|
743
|
-
"
|
|
744
|
-
"
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
{ key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request", schema: { type: "object" } }
|
|
1134
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: requested (attempt " + attemptN + ")" }) + "\n" +
|
|
1135
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("upsert-session", { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed",
|
|
1136
|
+
notes: "Baseline capture requested (attempt " + attemptN + ") — parent protocol: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map. The Capture step re-checks baseline evidence when re-run." }),
|
|
1137
|
+
{ key: "record-Capture-request" + bounceSuffix, label: "Recording baseline capture request" }
|
|
748
1138
|
);
|
|
1139
|
+
if (!VISUAL_PROTOCOL_AVAILABLE) {
|
|
1140
|
+
log("Capture: visual protocol not available (VISUAL_PROTOCOL_AVAILABLE=false) — recording baseline:none instead of parking for task " + taskId);
|
|
1141
|
+
await agent(
|
|
1142
|
+
"Record that baseline capture was skipped (protocol unavailable).\n" +
|
|
1143
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "note", identity: step.identity, message: "baseline: none (visual protocol unavailable)" }) + "\n" +
|
|
1144
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1145
|
+
task_id: taskId,
|
|
1146
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "completed", notes: "baseline: none — visual protocol unavailable, final QA judges on rubric alone" },
|
|
1147
|
+
event: { task_id: taskId, type: "completed", identity: step.identity, message: "Capture completed by " + step.identity }
|
|
1148
|
+
}),
|
|
1149
|
+
{ key: "record-Capture-none-protocol" + bounceSuffix, label: "Recording baseline: none (protocol unavailable)" }
|
|
1150
|
+
);
|
|
1151
|
+
i++;
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
749
1154
|
return await parkTask("Baseline capture requested (attempt " + attemptN + ") — parent: run the baseline capture protocol in docs/visual-verdict.md, then re-queue the task at Map");
|
|
750
1155
|
}
|
|
751
1156
|
|
|
@@ -761,11 +1166,12 @@ while (i < STEPS.length) {
|
|
|
761
1166
|
log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
|
|
762
1167
|
await agent(
|
|
763
1168
|
"Record the Map gate bounce.\n" +
|
|
764
|
-
"
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1169
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1170
|
+
task_id: taskId,
|
|
1171
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Map gate bounce: no baseline evidence recorded for this experiential task — no spec was written. Baseline evidence must be captured before the spec." },
|
|
1172
|
+
event: { task_id: taskId, type: "failed", identity: step.identity, message: "Map gate bounce — baseline evidence missing, returning to Capture" }
|
|
1173
|
+
}) + "\n",
|
|
1174
|
+
{ key: "record-Map-bounce" + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""), label: "Recording Map gate bounce" }
|
|
769
1175
|
);
|
|
770
1176
|
mapGateBounceCount++;
|
|
771
1177
|
i = CAPTURE_INDEX;
|
|
@@ -798,6 +1204,9 @@ while (i < STEPS.length) {
|
|
|
798
1204
|
instructions = "STEP 1: Prepare your worktree.\n" +
|
|
799
1205
|
"Run: "+ LIFECYCLE_ENV + " prepare " + taskId + "\n" +
|
|
800
1206
|
"If the output says CREATED or REUSED, proceed. If it says ERROR, stop and report the failure clearly.\n\n" +
|
|
1207
|
+
"HEARTBEAT: Start a background heartbeat loop NOW (before STEP 2) to signal you are still alive during this build. Run this once:\n" +
|
|
1208
|
+
"(while node " + CREW_API + " --crew-home " + crewHome + " heartbeat-session --json '{\"id\": \"" + activeSessionId + "\"}' >/dev/null 2>&1; do sleep 900; done) &\n" +
|
|
1209
|
+
"The loop heartbeats every 15 minutes and exits on its own when the session ends. This prevents the dispatcher from mistaking a long build for a dead session and launching a duplicate.\n\n" +
|
|
801
1210
|
"STEP 2: Edit source files to implement the mapper's spec below.\n" +
|
|
802
1211
|
(mapperSpec ? "MAPPER'S SPEC (implement exactly this):\n" + mapperSpec + "\n\n" : "") +
|
|
803
1212
|
"Your working directory: " + WORKTREE_HINT + "/\n" +
|
|
@@ -816,7 +1225,7 @@ while (i < STEPS.length) {
|
|
|
816
1225
|
"If the task's deliverable is runtime state (a cron definition, scheduler change, or dashboard/config state created outside the repo) and the repository genuinely needs no change, do NOT fabricate a commit: leave the branch with no commits ahead of main and declare `repo_diff: none` in your report, naming the runtime-state deliverable. Otherwise commit your changes normally.\n\n" +
|
|
817
1226
|
(rejectionNotes ? "REWORK after rejection. Address:\n" + rejectionNotes + "\n\n" : "") +
|
|
818
1227
|
"Report back in plain prose: what you built and the outcome." +
|
|
819
|
-
(PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
|
|
1228
|
+
(PUBLISH_TYPE === "npm" ? " End your report with the release: and version_bump: lines exactly as specified above — keep them on their own lines, lowercase, unrephrased — then a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then a final line with exactly: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not." : " End your report with a line `worktree: ` followed by the exact working directory path from above (copy it verbatim \u2014 it must match character-for-character), then exactly one line: VERDICT: PASS if the build is complete, VERDICT: FAIL if it is not.");
|
|
820
1229
|
|
|
821
1230
|
} else if (step.name === "Review") {
|
|
822
1231
|
instructions = "Review independently and cold. No prior context from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
|
|
@@ -852,14 +1261,19 @@ while (i < STEPS.length) {
|
|
|
852
1261
|
"R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
|
|
853
1262
|
"R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
|
|
854
1263
|
"R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
|
|
855
|
-
"ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
1264
|
+
"ESCALATE — report 'conflict needs human resolution', then end your report with exactly this line: VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
856
1265
|
"- If it contains ERROR, something else failed. Report the error, then end your report with exactly this line: VERDICT: FAIL.\n\n" +
|
|
857
1266
|
"\n" +
|
|
858
1267
|
"STEP 2: Push the merged main to the remote repository.\n" +
|
|
1268
|
+
"First, refresh the merge lock — a slow push must not let the 600s lease expire under you: " + LIFECYCLE_ENV + LIFECYCLE + " refresh-lock " + taskId + ". If the refresh fails, report 'merge lock lost before push' and end your report with exactly this line: VERDICT: FAIL. Do not push without the lock.\n" +
|
|
859
1269
|
"Run: cd " + REPO_PATH + " && git push origin main\n" +
|
|
860
1270
|
"- If the push succeeds, report the merged commit hash.\n" +
|
|
861
1271
|
"- If the push is rejected as non-fast-forward (the remote has commits not present locally),\n" +
|
|
862
|
-
" NEVER force-push. Do not run any --force variant.
|
|
1272
|
+
" NEVER force-push. Do not run any --force variant. The lifecycle's `integrate` already reconciled origin/main under the merge lock, so a rejection means the remote advanced after that reconcile — recover mechanically while you still hold the lock:\n" +
|
|
1273
|
+
" 1. Run: cd " + REPO_PATH + " && git fetch origin main && git merge --no-edit origin/main -m \"merge: push-time reconcile (" + taskId + ")\".\n" +
|
|
1274
|
+
" 2. If the merge conflicts, abort it (git merge --abort), report 'push-time reconcile conflicted — manual resolution required', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1275
|
+
" 3. If it merges cleanly, retry the push exactly once: git push origin main. If the second push still rejects, report 'push rejected twice as non-fast-forward', then end your report with exactly this line: VERDICT: FAIL.\n" +
|
|
1276
|
+
" 4. If the retry succeeds, report the merged commit hash.\n\n" +
|
|
863
1277
|
"Report back in plain prose — what happened at each step — and end your report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
|
|
864
1278
|
|
|
865
1279
|
} else if (step.name === "Publish") {
|
|
@@ -893,15 +1307,22 @@ while (i < STEPS.length) {
|
|
|
893
1307
|
// provenance was stamped — prose-trusted side effects, the same failure
|
|
894
1308
|
// class as the npm double-skip (bb739316). The npm path already runs one
|
|
895
1309
|
// deterministic script; the artifact path now has the same shape. Lock
|
|
896
|
-
// refresh, rebuild trigger, build-completion poll,
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
1310
|
+
// refresh, rebuild trigger, build-completion poll, and post-deploy are
|
|
1311
|
+
// narrow schema'd bookkeeping calls owned by the workflow — the work
|
|
1312
|
+
// agent reports on the mechanical outcome and cannot skip what it never
|
|
1313
|
+
// owned. Any step failing parks with an honest, step-specific reason
|
|
1314
|
+
// (fail-closed). There is deliberately NO workflow-side provenance
|
|
1315
|
+
// stamp: the builder's applied-report is circular (canary run 8,
|
|
1316
|
+
// 2026-09-11), so the stamp moved to the parent — after the build
|
|
1317
|
+
// lands, the workflow triggers an independent artifact_inspect
|
|
1318
|
+
// read-back, records the session completed, and parks with
|
|
1319
|
+
// "publish: verification-requested". The parent stamps provenance only
|
|
1320
|
+
// after the read-back confirms the content (docs/publish-verification.md);
|
|
1321
|
+
// Chore has no QA: the parent's stamp read-back is the final gate.
|
|
902
1322
|
var artifactPublish = null;
|
|
903
1323
|
var publishLockRefreshed = false;
|
|
904
1324
|
var publishSkippedNoLock = false;
|
|
1325
|
+
var publishBuildLanded = false; // true once the artifact build poll completes: content exists to verify; the provenance stamp is deferred to the parent (docs/publish-verification.md)
|
|
905
1326
|
try {
|
|
906
1327
|
// STEP 0 (mechanical): read the merge-lock state explicitly — never
|
|
907
1328
|
// infer it from prose. An empty-diff Integrate (MERGED_EMPTY)
|
|
@@ -924,31 +1345,297 @@ while (i < STEPS.length) {
|
|
|
924
1345
|
}
|
|
925
1346
|
publishLockRefreshed = true;
|
|
926
1347
|
}
|
|
927
|
-
// STEP 1 (mechanical):
|
|
1348
|
+
// STEP 1 (mechanical): carry the merged change to the artifact
|
|
1349
|
+
// builder. The builder's source tree is NOT the crew's repo —
|
|
1350
|
+
// canary run 4 (2026-09-11) proved it: Publish asked for "rebuild
|
|
1351
|
+
// from current source. Do not modify any source files" and the
|
|
1352
|
+
// builder rebuilt a stale copy predating the canary's changes, then
|
|
1353
|
+
// the workflow stamped the new commit hash on the stale build.
|
|
1354
|
+
// Provenance fiction; all eight phases passed. The merge diff is
|
|
1355
|
+
// embedded in the edit request; the builder applies it to its own
|
|
1356
|
+
// tree and reports the applied changes; the workflow verifies the
|
|
1357
|
+
// report matches the diff BEFORE stamping provenance. A mismatch
|
|
1358
|
+
// parks without stamping — the stamp must never certify a build
|
|
1359
|
+
// whose content was not verified.
|
|
928
1360
|
// Skipped entirely when no lock was held — nothing merged, nothing
|
|
929
1361
|
// to ship.
|
|
930
1362
|
if (!publishSkippedNoLock) {
|
|
931
|
-
// (below) the
|
|
932
|
-
//
|
|
933
|
-
//
|
|
1363
|
+
// (below) the diff computation, rebuild trigger, application
|
|
1364
|
+
// verification, bounded poll, and provenance stamp. The builder
|
|
1365
|
+
// only makes the artifact_edit call and reports the applied
|
|
1366
|
+
// changes — no prose claim to trust. If the artifact tool namespace
|
|
934
1367
|
// is missing from this child it reports honestly and the workflow
|
|
935
1368
|
// retries once with a fresh key (bounded); anything else parks.
|
|
1369
|
+
var diffResult = await agent(
|
|
1370
|
+
"Run: cd " + REPO_PATH + " && git rev-parse HEAD && echo '---PARENT---' && git rev-parse HEAD^1 && echo '---DIFF---' && git diff HEAD^1 HEAD && echo '---NAMES---' && git diff-tree --no-commit-id --name-only -r HEAD\n" +
|
|
1371
|
+
"Return JSON { \"commit\": \"<HEAD trimmed>\", \"parent\": \"<HEAD^1 trimmed>\", \"diff\": \"<raw unified diff, may be multi-line>\", \"files\": \"<newline-separated paths>\" } and nothing else.",
|
|
1372
|
+
{ key: attemptKey("publish-artifact-diff-" + taskId, reworkCount), label: "Computing merged diff for publish",
|
|
1373
|
+
schema: { type: "object", properties: { commit: { type: "string" }, parent: { type: "string" }, diff: { type: "string" }, files: { type: "string" } }, required: ["commit", "diff"] } }
|
|
1374
|
+
);
|
|
1375
|
+
var mergeCommitForPublish = (diffResult.commit || "").trim();
|
|
1376
|
+
var mergeParentForPublish = (diffResult.parent || "").trim();
|
|
1377
|
+
var mergeDiff = diffResult.diff || "";
|
|
1378
|
+
if (!mergeDiff.trim()) {
|
|
1379
|
+
return await parkTask("Publish diff is empty for commit " + (mergeCommitForPublish || "unknown") + " — a merge lock was held but there is no change to carry. Human attention needed.");
|
|
1380
|
+
}
|
|
1381
|
+
if (/^Binary files /m.test(mergeDiff)) {
|
|
1382
|
+
return await parkTask("Publish diff contains binary files — the text diff transport cannot carry them. Human attention needed.");
|
|
1383
|
+
}
|
|
1384
|
+
if (/^rename from /m.test(mergeDiff)) {
|
|
1385
|
+
return await parkTask("Publish diff contains a rename — the diff transport cannot carry renames. Human attention needed.");
|
|
1386
|
+
}
|
|
1387
|
+
var mergeDiffLines = mergeDiff.split("\n").length;
|
|
1388
|
+
if (mergeDiffLines > 200) {
|
|
1389
|
+
return await parkTask("Publish diff is " + mergeDiffLines + " lines (budget 200) — too large for the diff transport. Human attention needed.");
|
|
1390
|
+
}
|
|
1391
|
+
var expectedChanges = parseUnifiedDiff(mergeDiff);
|
|
1392
|
+
if (expectedChanges.length === 0) {
|
|
1393
|
+
return await parkTask("Publish diff parsed to zero files for commit " + (mergeCommitForPublish || "unknown") + " — cannot verify application. Human attention needed.");
|
|
1394
|
+
}
|
|
1395
|
+
// Pre-publish base observation (diagnostic, 2026-09-12): the builder
|
|
1396
|
+
// applies the diff to its own source tree, whose base state is
|
|
1397
|
+
// unrecorded. Compute the trustworthy expected base — the sha256 of
|
|
1398
|
+
// each touched file at the merge parent commit — so the builder's
|
|
1399
|
+
// self-reported pre-edit hashes (see buildPreHashInstruction) can be
|
|
1400
|
+
// compared against it. Observation only: a mismatch is logged loudly
|
|
1401
|
+
// but never parks. The observation tells us what the publish actually
|
|
1402
|
+
// reads, so the subsequent fix can require the right base.
|
|
1403
|
+
var expectedBaseHashes = {};
|
|
1404
|
+
try {
|
|
1405
|
+
// Shell-quote helper (no regex-with-quote: the test parser does not
|
|
1406
|
+
// understand regex literals containing quotes).
|
|
1407
|
+
var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
|
|
1408
|
+
var baseHashResult = await agent(
|
|
1409
|
+
"Run: cd " + REPO_PATH + " && parent=" + sq(mergeParentForPublish) + " && for f in " + expectedChanges.map(function(f) { return sq(f.path); }).join(" ") + "; do printf '%s:' \"$f\"; git show \"$parent:$f\" 2>/dev/null | sha256sum | cut -d' ' -f1; done\n" +
|
|
1410
|
+
"Return JSON { \"hashes\": \"<newline-separated <path>:<sha256> lines, empty hash means the file is new in this diff>\" } and nothing else.",
|
|
1411
|
+
{ key: attemptKey("publish-base-hashes-" + taskId, reworkCount), label: "Computing expected base content hashes",
|
|
1412
|
+
schema: { type: "object", properties: { hashes: { type: "string" } }, required: ["hashes"] } }
|
|
1413
|
+
);
|
|
1414
|
+
(baseHashResult.hashes || "").split("\n").forEach(function(line) {
|
|
1415
|
+
var m = /^([^:]+):([0-9a-f]*)$/.exec(line.trim());
|
|
1416
|
+
if (m) expectedBaseHashes[m[1]] = m[2] || "NEW-FILE";
|
|
1417
|
+
});
|
|
1418
|
+
log("Publish expected base hashes for task " + taskId + " (merge parent " + (mergeParentForPublish || "unknown").slice(0, 12) + "): " + JSON.stringify(expectedBaseHashes));
|
|
1419
|
+
} catch (e) {
|
|
1420
|
+
log("Publish expected base hash computation failed for task " + taskId + " (non-fatal, observation degraded): " + (e && e.message ? e.message : e));
|
|
1421
|
+
}
|
|
936
1422
|
var rebuildPrompt =
|
|
937
1423
|
"Call artifact_edit with slug \"" + PUBLISH_SLUG + "\" and verbatim_request:\n" +
|
|
938
|
-
"'
|
|
939
|
-
"
|
|
940
|
-
"
|
|
1424
|
+
"'Apply the following change to your source tree, then rebuild and deploy.\n" +
|
|
1425
|
+
"\n" +
|
|
1426
|
+
"UNIFIED DIFF (relative to your source tree):\n" +
|
|
1427
|
+
"```diff\n" + mergeDiff + "\n```\n" +
|
|
1428
|
+
"\n" +
|
|
1429
|
+
"Rules:\n" +
|
|
1430
|
+
"- For each file in the diff, apply its hunks to the same path in your source tree (use git apply or equivalent).\n" +
|
|
1431
|
+
"- For a new file (--- /dev/null), create it with the added (+) lines as its full content.\n" +
|
|
1432
|
+
"- For a deleted file (+++ /dev/null), delete it.\n" +
|
|
1433
|
+
"- If any hunk does not apply cleanly, STOP and report the failure — do not improvise or skip hunks.\n" +
|
|
1434
|
+
"- Do not make any other source changes.\n" +
|
|
1435
|
+
"- After applying, rebuild and deploy.\n" +
|
|
1436
|
+
"- Report, for each file you changed: its path, the exact lines you added, and the exact lines you removed.\n" +
|
|
1437
|
+
buildPreHashInstruction(expectedChanges) + "'\n" +
|
|
1438
|
+
ARTIFACT_LOAD_PREAMBLE +
|
|
1439
|
+
"If artifact_edit is still not available after the load, do NOT improvise — return { \"edit_started\": false, \"error\": \"artifact_tools missing after load\", \"applied\": [] }.\n" +
|
|
1440
|
+
"Make no other calls. Return JSON { \"edit_started\": <true if the edit was accepted, false otherwise>, \"error\": \"<details or empty string>\", \"applied\": [{\"path\": \"<file path>\", \"added\": [\"<added lines>\"], \"removed\": [\"<removed lines>\"]}], \"pre_hashes\": {\"<file path>\": \"<sha256 of that file's content BEFORE you applied the diff, or \"MISSING\">\"} } and nothing else.";
|
|
941
1441
|
var rebuildSchema =
|
|
942
|
-
{ type: "object",
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1442
|
+
{ type: "object",
|
|
1443
|
+
properties: {
|
|
1444
|
+
edit_started: { type: "boolean" },
|
|
1445
|
+
error: { type: "string" },
|
|
1446
|
+
applied: {
|
|
1447
|
+
type: "array",
|
|
1448
|
+
items: {
|
|
1449
|
+
type: "object",
|
|
1450
|
+
properties: {
|
|
1451
|
+
path: { type: "string" },
|
|
1452
|
+
added: { type: "array", items: { type: "string" } },
|
|
1453
|
+
removed: { type: "array", items: { type: "string" } }
|
|
1454
|
+
},
|
|
1455
|
+
required: ["path", "added", "removed"]
|
|
1456
|
+
}
|
|
1457
|
+
},
|
|
1458
|
+
pre_hashes: {
|
|
1459
|
+
type: "object",
|
|
1460
|
+
description: "Diagnostic (2026-09-12): sha256 of each touched file's content BEFORE the builder applied the diff, as reported by the builder. Compared against the workflow-computed expected base hashes (merge parent) — observation only, never gating."
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
required: ["edit_started", "applied"] };
|
|
1464
|
+
var rebuildTrigger = null;
|
|
1465
|
+
var rebuildReportMissing = false; // true if the edit went through but the agent returned no applied report (structured-output failure) — the smoke-check is skipped; the parent's independent read-back is the verification
|
|
1466
|
+
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1467
|
+
// Minted once here (not re-minted per use site) so the ledger always
|
|
1468
|
+
// records the exact key that was issued — and so a re-minted duplicate
|
|
1469
|
+
// can never drift from it.
|
|
1470
|
+
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, reworkCount);
|
|
1471
|
+
// The artifact build's agent_id, captured from artifact_status when the
|
|
1472
|
+
// trigger's outcome is ambiguous (structured-output failure). The
|
|
1473
|
+
// agent_id is the artifact system's in-flight correlation ID (not durable post-completion)
|
|
1474
|
+
// (research 2026-09-12): artifact.edit returns pending_init with NO
|
|
1475
|
+
// agent_id, but artifact_status exposes build.agent_id immediately
|
|
1476
|
+
// after acceptance, stable across polls. Recorded in the ledger so an
|
|
1477
|
+
// ambiguous attempt correlates to the exact builder run; null when no
|
|
1478
|
+
// build was ever observed.
|
|
1479
|
+
var rebuildAgentId = null;
|
|
1480
|
+
// The builder's applied-report is an observation, not a gate
|
|
1481
|
+
// (2026-09-12, task 23ca8f3f): computed once the trigger outcome is
|
|
1482
|
+
// known, logged loudly, never a park.
|
|
1483
|
+
var publishAppliedObservation = null; // "match" | "mismatch: <reason>" | "missing-report" — observation only, never a park
|
|
1484
|
+
try {
|
|
1485
|
+
rebuildTrigger = await agent(rebuildPrompt,
|
|
1486
|
+
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild", schema: rebuildSchema });
|
|
1487
|
+
} catch (rebuildErr) {
|
|
1488
|
+
// Structured-output failure (canary run 9, 2026-09-11): the agent
|
|
1489
|
+
// called artifact_edit (tool_call_count > 0) but returned prose
|
|
1490
|
+
// instead of JSON. The side effect may have happened — the outcome
|
|
1491
|
+
// is UNKNOWN, not "did not go through". The old code asked a child
|
|
1492
|
+
// for derived booleans and retried on all-false; that check issued
|
|
1493
|
+
// the DUPLICATE artifact_edit on 2026-09-12.
|
|
1494
|
+
//
|
|
1495
|
+
// Build-ID research (2026-09-12) corrected the model: artifact.edit
|
|
1496
|
+
// returns pending_init with NO agent_id, but artifact_status exposes
|
|
1497
|
+
// the build's agent_id (the artifact system's durable build
|
|
1498
|
+
// identifier, stable across polls) immediately after acceptance.
|
|
1499
|
+
// So the recovery no longer asks the child to derive booleans —
|
|
1500
|
+
// the layer where the 2026-09-12 signal was lost. It reads the RAW
|
|
1501
|
+
// build object and extracts build.agent_id mechanically in the
|
|
1502
|
+
// workflow script. An observed agent_id is positive evidence the
|
|
1503
|
+
// edit went through; no build after a bounded poll is still
|
|
1504
|
+
// inconclusive (unknown), never proof the edit failed. Mechanical
|
|
1505
|
+
// rule: never blind-retry on unknown — record the attempt and park
|
|
1506
|
+
// fail-closed; correlate via the ledger, never by re-issuing.
|
|
1507
|
+
log("Publish rebuild trigger: structured-output failure (" + (rebuildErr && rebuildErr.message ? rebuildErr.message : rebuildErr) + ") — checking build state before deciding; outcome unknown until state confirms it");
|
|
1508
|
+
var buildState = null;
|
|
1509
|
+
var buildStateFailed = false;
|
|
1510
|
+
try {
|
|
1511
|
+
buildState = await agent(
|
|
1512
|
+
ARTIFACT_LOAD_PREAMBLE +
|
|
1513
|
+
"Call artifact_status with slug \"" + PUBLISH_SLUG + "\".\n" +
|
|
1514
|
+
"Poll up to 3 times, about 20 seconds apart, until the response shows a build (the \"build\" value is an object, not null). " +
|
|
1515
|
+
"Return the raw \"build\" value verbatim as JSON — the build object exactly as returned, with its agent_id, operation, status, and any other fields untouched. " +
|
|
1516
|
+
"Do not summarize, interpret, or derive booleans from it. " +
|
|
1517
|
+
"If no build appears after 3 polls, return null. " +
|
|
1518
|
+
"Return JSON { \"build\": <the raw build object or null> } and nothing else.",
|
|
1519
|
+
{ key: attemptKey("publish-artifact-buildcheck-" + taskId, reworkCount), label: "Reading artifact build state after trigger failure",
|
|
1520
|
+
schema: { type: "object", properties: { build: { type: ["object", "null"] } }, required: ["build"] } }
|
|
1521
|
+
);
|
|
1522
|
+
} catch (buildCheckErr) {
|
|
1523
|
+
buildStateFailed = true;
|
|
1524
|
+
log("Publish rebuild trigger: build-state check itself failed (" + (buildCheckErr && buildCheckErr.message ? buildCheckErr.message : buildCheckErr) + ") — treating the outcome as unknown");
|
|
1525
|
+
}
|
|
1526
|
+
var acceptedAgentId = (buildState && buildState.build && typeof buildState.build.agent_id === "string" && buildState.build.agent_id) || null;
|
|
1527
|
+
if (!buildStateFailed && acceptedAgentId) {
|
|
1528
|
+
// The edit went through — the agent just failed to return JSON.
|
|
1529
|
+
// The build's agent_id is positive evidence: it appears in
|
|
1530
|
+
// artifact_status immediately after our accepted edit (pending_init
|
|
1531
|
+
// acceptance is followed by a visible build with a stable agent_id,
|
|
1532
|
+
// per the 2026-09-12 research). No applied report to smoke-check;
|
|
1533
|
+
// the parent's independent read-back (docs/publish-verification.md)
|
|
1534
|
+
// is the real verification, not the circular applied-report. The
|
|
1535
|
+
// agent_id is recorded in the ledger so this attempt correlates to
|
|
1536
|
+
// the exact builder run, not just commit + attempt key.
|
|
1537
|
+
log("Publish rebuild trigger: artifact_status shows build " + acceptedAgentId + " for slug " + PUBLISH_SLUG + " — the edit went through despite the structured-output failure. Skipping applied-report smoke-check; parent read-back is the verification.");
|
|
1538
|
+
rebuildTrigger = { edit_started: true, error: "", applied: null };
|
|
1539
|
+
rebuildReportMissing = true;
|
|
1540
|
+
rebuildAgentId = acceptedAgentId;
|
|
1541
|
+
} else {
|
|
1542
|
+
// No build observed — but that proves nothing (a fast-completing
|
|
1543
|
+
// build can finish between polls, or the check itself failed). The
|
|
1544
|
+
// outcome is UNKNOWN. No retry: re-issuing the edit here duplicated
|
|
1545
|
+
// it on 2026-09-12. Record the attempt durably and park fail-closed;
|
|
1546
|
+
// correlate via the ledger, never by guessing from a blind poll.
|
|
1547
|
+
log("Publish rebuild trigger: no build observed after structured-output failure — outcome UNKNOWN. Recording the attempt and parking fail-closed; no blind retry.");
|
|
1548
|
+
await recordPublishLedger({
|
|
1549
|
+
commit: mergeCommitForPublish,
|
|
1550
|
+
attempt: rebuildAttemptKey,
|
|
1551
|
+
agent_id: null,
|
|
1552
|
+
applied_report: null,
|
|
1553
|
+
outcome: "unknown",
|
|
1554
|
+
detail: "structured-output failure on rebuild trigger; build-state poll saw no build (or the check itself failed); edit may have been accepted as pending_init"
|
|
1555
|
+
}, reworkCount);
|
|
1556
|
+
return await parkTask("Publish outcome unknown: the rebuild trigger's child did not return JSON, and the follow-up build-state poll could not observe a build for slug " + PUBLISH_SLUG + ". The edit may have been accepted as pending_init, so no retry was issued — a blind retry duplicated the edit on 2026-09-12. The attempt is recorded in the publish ledger at " + crewHome + "/.publish-ledger/" + PUBLISH_SLUG + ".jsonl (commit " + String(mergeCommitForPublish || "unknown").slice(0, 12) + "). Correlate the accepted edit via the ledger and the builder's eventual completion before re-driving Publish. Fail-closed.");
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (!rebuildReportMissing && !rebuildTrigger.edit_started && rebuildTrigger.error === "artifact_tools missing after load") {
|
|
1560
|
+
log("Publish rebuild trigger: artifact_tools missing after load — one bounded retry with a fresh key");
|
|
947
1561
|
rebuildTrigger = await agent(rebuildPrompt,
|
|
948
|
-
{ key: attemptKey("publish-artifact-rebuild-" + taskId + "-
|
|
1562
|
+
{ key: attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", reworkCount), label: "Triggering artifact rebuild (retry)", schema: rebuildSchema });
|
|
1563
|
+
rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId + "-retry2", reworkCount);
|
|
1564
|
+
}
|
|
1565
|
+
// Durable publish-attempt ledger: record the trigger outcome while the
|
|
1566
|
+
// attempt key and commit are in scope. Every attempt lands here with
|
|
1567
|
+
// its outcome — submitted, rejected, or unknown (unknown is recorded
|
|
1568
|
+
// at the park site above). A later run or human matches commit hash +
|
|
1569
|
+
// attempt key against the builder's eventual completion.
|
|
1570
|
+
if (rebuildTrigger && rebuildTrigger.edit_started) {
|
|
1571
|
+
// Applied-report observation (2026-09-12, task 23ca8f3f): the
|
|
1572
|
+
// builder's applied-report is logged as observation only — it
|
|
1573
|
+
// never parks. The report is derived from the carried diff, so a
|
|
1574
|
+
// "match" certifies nothing (canary run 8); and it has a
|
|
1575
|
+
// demonstrated false-negative mode (applied:[] for a diff the
|
|
1576
|
+
// builder had actually applied). The independent read-back below
|
|
1577
|
+
// plus the parent protocol (docs/publish-verification.md) are the
|
|
1578
|
+
// verification — this block always proceeds to them.
|
|
1579
|
+
publishAppliedObservation = rebuildReportMissing
|
|
1580
|
+
? "missing-report"
|
|
1581
|
+
: (function () { var c = verifyAppliedChanges(expectedChanges, rebuildTrigger.applied); return c.ok ? "match" : "mismatch: " + c.reason; })();
|
|
1582
|
+
log("Publish applied-report observation for task " + taskId + ": " + publishAppliedObservation + " — observation only, never a park: the applied report is derived from the carried diff and proved unreliable in the false-negative direction (task 23ca8f3f, 2026-09-12: applied:[] for a diff the builder had applied). The independent read-back below plus the parent protocol are the verification.");
|
|
1583
|
+
await recordPublishLedger({
|
|
1584
|
+
commit: mergeCommitForPublish,
|
|
1585
|
+
attempt: rebuildAttemptKey,
|
|
1586
|
+
agent_id: rebuildAgentId,
|
|
1587
|
+
applied_report: publishAppliedObservation,
|
|
1588
|
+
outcome: "submitted",
|
|
1589
|
+
detail: rebuildReportMissing
|
|
1590
|
+
? "edit confirmed via build-state poll after structured-output failure (build " + (rebuildAgentId || "agent_id unknown") + "); builder applied-report missing"
|
|
1591
|
+
: "edit accepted; builder applied-report received"
|
|
1592
|
+
}, reworkCount);
|
|
1593
|
+
} else if (rebuildTrigger) {
|
|
1594
|
+
await recordPublishLedger({
|
|
1595
|
+
commit: mergeCommitForPublish,
|
|
1596
|
+
attempt: rebuildAttemptKey,
|
|
1597
|
+
applied_report: null,
|
|
1598
|
+
outcome: "rejected",
|
|
1599
|
+
detail: "edit not accepted: " + (rebuildTrigger.error || "no error detail")
|
|
1600
|
+
}, reworkCount);
|
|
949
1601
|
}
|
|
950
1602
|
var publishFailure = null;
|
|
951
1603
|
if (rebuildTrigger.edit_started) {
|
|
1604
|
+
// STEP 1b (observation only): publishAppliedObservation was
|
|
1605
|
+
// computed and logged above, inside the ledger block — the
|
|
1606
|
+
// builder's applied-report never parks and never blocks the stamp.
|
|
1607
|
+
// Task 23ca8f3f (2026-09-12) proved its false-negative mode:
|
|
1608
|
+
// applied:[] for a diff the builder had actually applied, which
|
|
1609
|
+
// parked a successful publish as unverified. A "match" certifies
|
|
1610
|
+
// nothing either — the report is derived from the carried diff
|
|
1611
|
+
// (canary run 8). The flow proceeds to the build poll and the
|
|
1612
|
+
// independent read-back trigger regardless of what the report
|
|
1613
|
+
// claimed; real verification is the parent's read-back
|
|
1614
|
+
// (docs/publish-verification.md) before the provenance stamp.
|
|
1615
|
+
// Pre-publish base observation (diagnostic, 2026-09-12): compare
|
|
1616
|
+
// the builder's self-reported pre-edit hashes against the
|
|
1617
|
+
// workflow-computed expected base (merge parent). This tells us
|
|
1618
|
+
// what base state the publish actually read. OBSERVATION ONLY —
|
|
1619
|
+
// a mismatch is logged loudly but never parks and never blocks
|
|
1620
|
+
// the stamp. If the tree was dirty or drifted, the evidence is
|
|
1621
|
+
// here; the fix (requiring the right base) comes after we see it.
|
|
1622
|
+
try {
|
|
1623
|
+
var preHashes = (rebuildTrigger && rebuildTrigger.pre_hashes) || {};
|
|
1624
|
+
var baseLines = expectedChanges.map(function(f) {
|
|
1625
|
+
var expected = expectedBaseHashes[f.path];
|
|
1626
|
+
var actual = preHashes[f.path];
|
|
1627
|
+
var expShort = (expected || "UNKNOWN").slice(0, 12);
|
|
1628
|
+
var actShort = String(actual || "NOT-REPORTED").slice(0, 12);
|
|
1629
|
+
var match = (expected !== undefined && actual !== undefined) ? (expected === actual) : "unknown";
|
|
1630
|
+
return " " + f.path + ": expected_base=" + expShort + " builder_pre=" + actShort + " match=" + match;
|
|
1631
|
+
});
|
|
1632
|
+
var anyMismatch = expectedChanges.some(function(f) {
|
|
1633
|
+
return expectedBaseHashes[f.path] !== undefined && preHashes[f.path] !== undefined && expectedBaseHashes[f.path] !== preHashes[f.path];
|
|
1634
|
+
});
|
|
1635
|
+
log("Publish pre-tree base observation for task " + taskId + (anyMismatch ? " — BASE MISMATCH DETECTED (tree was not at the expected merge-parent state when the diff was applied):" : " — base matches expected merge-parent state:") + "\n" + baseLines.join("\n"));
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
log("Publish pre-tree base observation failed for task " + taskId + " (non-fatal): " + (e && e.message ? e.message : e));
|
|
1638
|
+
}
|
|
952
1639
|
// STEP 1b (mechanical): bounded poll for build completion, chunked so
|
|
953
1640
|
// the merge-lock lease is refreshed before it can expire. The 600s
|
|
954
1641
|
// lease is shorter than the worst-case 10-minute build poll, so the
|
|
@@ -978,7 +1665,7 @@ while (i < STEPS.length) {
|
|
|
978
1665
|
? attemptKey("publish-artifact-poll-" + taskId, reworkCount)
|
|
979
1666
|
: attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, reworkCount);
|
|
980
1667
|
buildPoll = await agent(
|
|
981
|
-
"
|
|
1668
|
+
"First call tool_search.load_tool_namespace with paths [\"artifact\"]. Then poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
|
|
982
1669
|
"Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
|
|
983
1670
|
{ key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
|
|
984
1671
|
schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
|
|
@@ -990,22 +1677,20 @@ while (i < STEPS.length) {
|
|
|
990
1677
|
buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
|
|
991
1678
|
}
|
|
992
1679
|
if (buildPoll.build_done) {
|
|
993
|
-
// STEP 1c (mechanical):
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
publishFailure = "Provenance stamp failed after a completed build (source_commit " + (provStamp.source_commit || "unknown") + "). The build landed but is unstamped — fail-closed.";
|
|
1008
|
-
}
|
|
1680
|
+
// STEP 1c (mechanical): NO provenance stamp here. Canary run 8
|
|
1681
|
+
// (2026-09-11) proved the stamp cannot certify content: the
|
|
1682
|
+
// builder's applied-report is derived from the carried diff, so
|
|
1683
|
+
// verifyAppliedChanges above is circular — a fabricated report
|
|
1684
|
+
// passes by construction, and every phase went green on a hollow
|
|
1685
|
+
// build. The stamp moves to the parent (docs/publish-verification.md):
|
|
1686
|
+
// after an independent artifact_inspect read-back confirms the
|
|
1687
|
+
// artifact's actual content matches the merged diff, the parent
|
|
1688
|
+
// stamps provenance and re-queues; QA's provenance check then
|
|
1689
|
+
// enforces the stamp mechanically, so an unverified publish fails
|
|
1690
|
+
// loudly in QA instead of passing silently here.
|
|
1691
|
+
publishBuildLanded = true;
|
|
1692
|
+
artifactPublish = { source_commit: mergeCommitForPublish, pending_parent_verification: true };
|
|
1693
|
+
log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
|
|
1009
1694
|
} else {
|
|
1010
1695
|
publishFailure = "Artifact build did not complete within budget: " + (buildPoll.status || "timeout") + ". The publish may or may not have landed — provenance was not stamped.";
|
|
1011
1696
|
}
|
|
@@ -1036,9 +1721,9 @@ while (i < STEPS.length) {
|
|
|
1036
1721
|
: " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
|
|
1037
1722
|
}
|
|
1038
1723
|
if (!postDeploy.deployed) {
|
|
1039
|
-
return await parkTask("Post-deploy failed after
|
|
1724
|
+
return await parkTask("Post-deploy failed after the artifact build landed: " + (postDeploy.output || "no output") + ". The build may have landed but worktree cleanup and lock release are unknown — human attention needed.");
|
|
1040
1725
|
}
|
|
1041
|
-
log("Deterministic artifact publish completed for task " + taskId + ":
|
|
1726
|
+
log("Deterministic artifact publish completed for task " + taskId + ": build at " + artifactPublish.source_commit + ", provenance PENDING parent content verification");
|
|
1042
1727
|
}
|
|
1043
1728
|
} catch (pubErr) {
|
|
1044
1729
|
// Best-effort cleanup: if the lock was refreshed, try to release it
|
|
@@ -1048,8 +1733,7 @@ while (i < STEPS.length) {
|
|
|
1048
1733
|
await agent(
|
|
1049
1734
|
"Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
|
|
1050
1735
|
"Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise> } and nothing else.",
|
|
1051
|
-
{ key: attemptKey("publish-postdeploy-cleanup-" + taskId, reworkCount), label: "Releasing lock after publish failure"
|
|
1052
|
-
schema: { type: "object", properties: { deployed: { type: "boolean" } }, required: ["deployed"] } }
|
|
1736
|
+
{ key: attemptKey("publish-postdeploy-cleanup-" + taskId, reworkCount), label: "Releasing lock after publish failure" }
|
|
1053
1737
|
);
|
|
1054
1738
|
} catch (cleanupErr) {
|
|
1055
1739
|
log("Publish cleanup post-deploy also failed: " + (cleanupErr && cleanupErr.message ? cleanupErr.message : cleanupErr));
|
|
@@ -1069,10 +1753,10 @@ while (i < STEPS.length) {
|
|
|
1069
1753
|
instructions = "Publish the merged code to the live artifact.\n\n" +
|
|
1070
1754
|
"The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
|
|
1071
1755
|
"For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
|
|
1072
|
-
"Mechanical outcome (
|
|
1756
|
+
"Mechanical outcome (the workflow's mechanical steps; content verification is the parent's, still pending):\n" +
|
|
1073
1757
|
"- merge lock refreshed: yes\n" +
|
|
1074
1758
|
"- artifact rebuild triggered and completed: yes\n" +
|
|
1075
|
-
"- provenance stamped:
|
|
1759
|
+
"- provenance stamped: NO — not yet, and you must NOT stamp it. The stamp moved to the parent, which stamps it only after an independent content read-back confirms the artifact actually contains the merged change (docs/publish-verification.md). A stamped-but-hollow build is exactly how canary run 8 (2026-09-11) went green on stale content.\n" +
|
|
1076
1760
|
"- post-deploy finalized: yes (worktree removed, merge lock released)\n\n" +
|
|
1077
1761
|
"POLICY: The live artifact is rebuilt only in this phase, from the repo. Never use artifact_edit to change the artifact directly — fixes go through the repo and the loop. A source fix is not done until the artifact is rebuilt from it here.\n\n" +
|
|
1078
1762
|
"The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
|
|
@@ -1094,7 +1778,7 @@ while (i < STEPS.length) {
|
|
|
1094
1778
|
var eventPreamble = "";
|
|
1095
1779
|
if (step.name !== "Review" && step.name !== "Publish") {
|
|
1096
1780
|
eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
|
|
1097
|
-
"
|
|
1781
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-events", { task_id: taskId }) + "\n" +
|
|
1098
1782
|
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
1099
1783
|
}
|
|
1100
1784
|
|
|
@@ -1105,10 +1789,11 @@ while (i < STEPS.length) {
|
|
|
1105
1789
|
// string. The verdict is still extracted deterministically from the report
|
|
1106
1790
|
// text by extractVerdict below — never by an agent.
|
|
1107
1791
|
var workPromptBase =
|
|
1792
|
+
TOOL_CHECK_PREAMBLE +
|
|
1108
1793
|
"Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
|
|
1109
1794
|
"## Your Assignment\n\n" +
|
|
1110
1795
|
"Task: " + taskTitle + "\nTask ID: " + taskId + "\nDescription: " + taskDescription + "\nStep: " + step.name + "\n" +
|
|
1111
|
-
(step.name !== "Review" ? "
|
|
1796
|
+
(step.name !== "Review" ? "Crew API: " + CREW_API + "\n" : "") +
|
|
1112
1797
|
"\n## Instructions\n\n" + eventPreamble + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. Do the work thoroughly.\n\n" +
|
|
1113
1798
|
"Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your report here\"}. " +
|
|
1114
1799
|
"The result is plain prose describing what you did and found. For verdict steps, end the report with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
|
|
@@ -1117,7 +1802,8 @@ while (i < STEPS.length) {
|
|
|
1117
1802
|
var workAttempts = [];
|
|
1118
1803
|
for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
|
|
1119
1804
|
var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
|
|
1120
|
-
var
|
|
1805
|
+
var prevAttempt = workAttempt === 0 ? null : workAttempts[workAttempt - 1];
|
|
1806
|
+
var retryReason = workAttempt === 0 ? null : (prevAttempt.threw ? "discarded" : (prevAttempt.outcome === "missing-artifact-tools" ? "no-tools" : (prevAttempt.outcome === "unavailable-shell-transport" ? "no-transport" : "empty")));
|
|
1121
1807
|
try {
|
|
1122
1808
|
workerResult = await agent(
|
|
1123
1809
|
workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
|
|
@@ -1132,6 +1818,22 @@ while (i < STEPS.length) {
|
|
|
1132
1818
|
// always fails (regression shipped in ecef136 when Date.now() was
|
|
1133
1819
|
// removed from this loop).
|
|
1134
1820
|
if (typeof workerResult === "string" && workerResult.trim()) {
|
|
1821
|
+
// Bug 3472bf36: a worker whose TOOL CHECK reports artifact_tools: missing
|
|
1822
|
+
// (or shell_transport: unavailable) gets a fresh launch - the load is
|
|
1823
|
+
// per-launch - instead of a useless report.
|
|
1824
|
+
var toolSignals = parseToolSignals(workerResult);
|
|
1825
|
+
if (toolSignals.artifactTools === "missing") {
|
|
1826
|
+
workAttempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
|
|
1827
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 reported artifact_tools: missing — retrying with a fresh launch");
|
|
1828
|
+
workerResult = null;
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
if (toolSignals.shellTransport === "unavailable") {
|
|
1832
|
+
workAttempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
|
|
1833
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 reported shell_transport: unavailable — retrying with a fresh launch");
|
|
1834
|
+
workerResult = null;
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1135
1837
|
if (workAttempt > 0) log(step.name + " work agent transport retry " + workAttempt + " returned a machine-readable report");
|
|
1136
1838
|
break;
|
|
1137
1839
|
}
|
|
@@ -1154,11 +1856,12 @@ while (i < STEPS.length) {
|
|
|
1154
1856
|
log(step.name + " " + workFailure.notes + " — marking failed for retry");
|
|
1155
1857
|
await agent(
|
|
1156
1858
|
"Record work failure.\n" +
|
|
1157
|
-
"
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1859
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1860
|
+
task_id: taskId,
|
|
1861
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: workFailure.notes },
|
|
1862
|
+
event: { task_id: taskId, type: "failed", message: workFailure.eventMessage }
|
|
1863
|
+
}) + "\n",
|
|
1864
|
+
{ key: "record-block-" + step.name, label: "Recording work failure" }
|
|
1162
1865
|
);
|
|
1163
1866
|
return {
|
|
1164
1867
|
__hatchWorkflowControl: "blocked",
|
|
@@ -1192,11 +1895,12 @@ while (i < STEPS.length) {
|
|
|
1192
1895
|
log(step.name + " verdict re-ask exhausted — marking failed for retry");
|
|
1193
1896
|
await agent(
|
|
1194
1897
|
"Record verdict failure.\n" +
|
|
1195
|
-
"
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1898
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1899
|
+
task_id: taskId,
|
|
1900
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
|
|
1901
|
+
event: { task_id: taskId, type: "failed", message: step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" }
|
|
1902
|
+
}) + "\n",
|
|
1903
|
+
{ key: "record-block-" + step.name, label: "Recording verdict failure" }
|
|
1200
1904
|
);
|
|
1201
1905
|
return {
|
|
1202
1906
|
__hatchWorkflowControl: "blocked",
|
|
@@ -1210,6 +1914,37 @@ while (i < STEPS.length) {
|
|
|
1210
1914
|
verdictPassed = verdict.passed;
|
|
1211
1915
|
}
|
|
1212
1916
|
|
|
1917
|
+
|
|
1918
|
+
// Worktree confinement (Build only): the declared worktree path must
|
|
1919
|
+
// match WORKTREE_HINT exactly. A builder that worked in any other
|
|
1920
|
+
// checkout fails the phase here — the dispatcher retries Build under
|
|
1921
|
+
// its consecutive-failure cap, and the retry re-runs prepare against
|
|
1922
|
+
// the configured repo. Missing or mismatched lines fail closed.
|
|
1923
|
+
if (step.name === "Build" && verdictPassed === true) {
|
|
1924
|
+
var wt = extractWorktree(workerText);
|
|
1925
|
+
if (!wt.ok || wt.path !== WORKTREE_HINT) {
|
|
1926
|
+
log("Build worktree confinement failed — declared: " + (wt.ok ? wt.path : "<none>") + ", expected: " + WORKTREE_HINT + " — marking failed for retry");
|
|
1927
|
+
await agent(
|
|
1928
|
+
"Record worktree confinement failure.\n" +
|
|
1929
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
1930
|
+
task_id: taskId,
|
|
1931
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: "failed", notes: "Build declared worktree " + (wt.ok ? wt.path : "<none>") + " — expected " + WORKTREE_HINT + ". The builder worked outside the configured repo checkout; phase failed for retry" },
|
|
1932
|
+
event: { task_id: taskId, type: "failed", message: "Build worktree confinement failed — builder worked outside " + WORKTREE_HINT + ", phase failed, dispatcher will retry" }
|
|
1933
|
+
}) + "\n",
|
|
1934
|
+
{ key: "record-worktree-fail-" + step.name, label: "Recording worktree confinement failure" }
|
|
1935
|
+
);
|
|
1936
|
+
return {
|
|
1937
|
+
__hatchWorkflowControl: "blocked",
|
|
1938
|
+
result: {
|
|
1939
|
+
blocked_reason: "Build worked outside the configured repo checkout",
|
|
1940
|
+
message: "The builder declared worktree " + (wt.ok ? wt.path : "<none>") + " but the task's worktree is " + WORKTREE_HINT + ". The phase is marked failed; the dispatcher will retry Build against the configured repo.",
|
|
1941
|
+
task_id: taskId
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
log("Build worktree confinement passed: " + wt.path);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1213
1948
|
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
1214
1949
|
// (extractVerdict above); the summary is the worker's report truncated.
|
|
1215
1950
|
// For verdict steps passed comes from the verdict; for non-verdict steps
|
|
@@ -1303,13 +2038,13 @@ while (i < STEPS.length) {
|
|
|
1303
2038
|
// dashboard QA source check).
|
|
1304
2039
|
try {
|
|
1305
2040
|
var provRefresh = await agent(
|
|
1306
|
-
"
|
|
2041
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-provenance", {}) + "\n" +
|
|
1307
2042
|
"If the response has no provenance (null), return JSON { \"refreshed\": false, \"reason\": \"no-record\" } and stop. " +
|
|
1308
2043
|
"Otherwise run: basename $(readlink " + crewHome + "/current) — call this REL; " +
|
|
1309
2044
|
"run: date -u +%Y-%m-%dT%H:%M:%SZ — call this TS. " +
|
|
1310
|
-
"
|
|
1311
|
-
"
|
|
1312
|
-
"Return JSON { \"refreshed\": <true if the
|
|
2045
|
+
"Then run in shell:\n" + crewCmd("set-provenance", { source_commit: "<existing provenance.source_commit>", crew_release: "<REL trimmed>", published_at: "<TS trimmed>", task_id: taskId }) + "\n" +
|
|
2046
|
+
"(substitute the real existing source_commit, REL, and TS for the placeholders). " +
|
|
2047
|
+
"Return JSON { \"refreshed\": <true if the set-provenance stdout contains ok: true, false otherwise>, \"crew_release\": \"<REL trimmed>\", \"published_at\": \"<TS trimmed>\" } and nothing else.",
|
|
1313
2048
|
{ key: attemptKey("publish-provenance-refresh-" + taskId, reworkCount), label: "Refreshing dashboard provenance after crew release",
|
|
1314
2049
|
schema: { type: "object", properties: { refreshed: { type: "boolean" }, reason: { type: "string" }, crew_release: { type: "string" }, published_at: { type: "string" } }, required: ["refreshed"] } }
|
|
1315
2050
|
);
|
|
@@ -1330,43 +2065,51 @@ while (i < STEPS.length) {
|
|
|
1330
2065
|
} // end: !npmPublishSkipped — a skipped publish has nothing to verify
|
|
1331
2066
|
}
|
|
1332
2067
|
|
|
1333
|
-
//
|
|
1334
|
-
// The
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
2068
|
+
// Publish content verification — parent-owned (docs/publish-verification.md).
|
|
2069
|
+
// The old block read back the workflow's OWN provenance stamp and compared
|
|
2070
|
+
// it to HEAD: that verifies the stamp, not the content. Canary run 8
|
|
2071
|
+
// (2026-09-11) passed it with a hollow build — the stamp was honest, the
|
|
2072
|
+
// artifact was stale, all eight phases green. The stamp now moves to the
|
|
2073
|
+
// parent: trigger an independent artifact_inspect read-back of the changed
|
|
2074
|
+
// regions here; the parent stamps provenance only after mechanically
|
|
2075
|
+
// confirming the artifact's actual content matches the merged diff. QA's
|
|
2076
|
+
// provenance check enforces the stamp — an unverified publish fails loudly
|
|
2077
|
+
// there instead of passing silently here.
|
|
2078
|
+
// Skip-aware (park 2026-09-11): an empty-diff Integrate takes no merge
|
|
2079
|
+
// lock, and the deterministic publish path skips rebuild/stamp entirely —
|
|
2080
|
+
// there is no new content to verify, so verification is vacuous.
|
|
2081
|
+
// publishSkippedNoLock is workflow-computed state from the explicit
|
|
2082
|
+
// lock-status read in STEP 0, not agent prose.
|
|
2083
|
+
var publishVerifyInspect = { triggered: false, inspection_id: "", error: "" };
|
|
1337
2084
|
if (step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG) {
|
|
1338
|
-
// Skip-aware (park 2026-09-11): an empty-diff Integrate takes no merge
|
|
1339
|
-
// lock, and the deterministic publish path skips rebuild/stamp entirely —
|
|
1340
|
-
// there is no new provenance to compare against HEAD, so verification is
|
|
1341
|
-
// vacuous. publishSkippedNoLock is workflow-computed state from the
|
|
1342
|
-
// explicit lock-status read in STEP 0, not agent prose.
|
|
1343
2085
|
if (publishSkippedNoLock) {
|
|
1344
|
-
log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate):
|
|
2086
|
+
log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): content verification vacuous, nothing was shipped");
|
|
2087
|
+
} else if (!publishBuildLanded) {
|
|
2088
|
+
log("Publish build did not land for task " + taskId + " — no content to verify (the failure park above already fired)");
|
|
1345
2089
|
} else {
|
|
1346
2090
|
try {
|
|
1347
|
-
var
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
"Return JSON { \"source_commit\": \"<provenance.source_commit>\", \"published_at\": \"<provenance.published_at>\" } and nothing else.",
|
|
1356
|
-
{ key: attemptKey("verify-publish-prov-" + taskId, reworkCount), label: "Verifying artifact provenance",
|
|
1357
|
-
schema: { type: "object", properties: { source_commit: { type: "string" }, published_at: { type: "string" } }, required: ["source_commit"] } }
|
|
2091
|
+
var inspectResult = await agent(
|
|
2092
|
+
ARTIFACT_LOAD_PREAMBLE +
|
|
2093
|
+
"Call artifact_inspect with slug \"" + PUBLISH_SLUG + "\", repair_authorized false, and verbatim_request exactly as follows:\n" +
|
|
2094
|
+
"<<<READBACK_REQUEST\n" + buildPublishReadbackRequest(taskId, mergeCommitForPublish, mergeDiff, rebuildAgentId) + "\nREADBACK_REQUEST\n" +
|
|
2095
|
+
"If artifact_inspect is still not available after the load, do NOT improvise — return { \"triggered\": false, \"inspection_id\": \"\", \"error\": \"artifact_tools missing after load\" } and nothing else.\n" +
|
|
2096
|
+
"Return JSON { \"triggered\": <true if the inspection started, false otherwise>, \"inspection_id\": \"<the inspection id, or empty string>\", \"error\": \"<details or empty string>\" } and nothing else.",
|
|
2097
|
+
{ key: attemptKey("publish-verify-inspect-" + taskId, reworkCount), label: "Triggering publish content read-back",
|
|
2098
|
+
schema: { type: "object", properties: { triggered: { type: "boolean" }, inspection_id: { type: "string" }, error: { type: "string" } }, required: ["triggered"] } }
|
|
1358
2099
|
);
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2100
|
+
publishVerifyInspect.triggered = !!(inspectResult && inspectResult.triggered);
|
|
2101
|
+
publishVerifyInspect.inspection_id = (inspectResult && inspectResult.inspection_id) || "";
|
|
2102
|
+
publishVerifyInspect.error = (inspectResult && inspectResult.error) || "";
|
|
2103
|
+
if (publishVerifyInspect.triggered) {
|
|
2104
|
+
log("Publish content read-back inspection triggered for task " + taskId + ": " + publishVerifyInspect.inspection_id);
|
|
2105
|
+
} else {
|
|
2106
|
+
log("Publish content read-back inspect trigger failed for task " + taskId + ": " + (publishVerifyInspect.error || "not started") + " — the park below asks the parent to trigger it manually");
|
|
1363
2107
|
}
|
|
1364
|
-
log("Publish verified for task " + taskId + ": artifact provenance at " + provCommit);
|
|
1365
|
-
publishVerified = true;
|
|
1366
2108
|
} catch (e) {
|
|
1367
|
-
|
|
2109
|
+
publishVerifyInspect.error = (e && e.message ? e.message : String(e)).slice(0, 200);
|
|
2110
|
+
log("Publish content read-back inspect trigger threw for task " + taskId + ": " + publishVerifyInspect.error + " — the park below asks the parent to trigger it manually");
|
|
1368
2111
|
}
|
|
1369
|
-
} // end: !publishSkippedNoLock — a skipped publish has nothing to verify
|
|
2112
|
+
} // end: !publishSkippedNoLock && publishBuildLanded — a skipped or failed publish has nothing to verify
|
|
1370
2113
|
}
|
|
1371
2114
|
|
|
1372
2115
|
// Session notes. Machine-readable marker lines are extracted from the full
|
|
@@ -1408,14 +2151,14 @@ while (i < STEPS.length) {
|
|
|
1408
2151
|
|
|
1409
2152
|
await agent(
|
|
1410
2153
|
"Update session and log event.\n" +
|
|
1411
|
-
"
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2154
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2155
|
+
task_id: taskId,
|
|
2156
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary },
|
|
2157
|
+
event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
|
|
2158
|
+
}) + "\n",
|
|
1415
2159
|
{
|
|
1416
2160
|
key: "record-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "") + (mapGateBounceCount > 0 ? "-g" + mapGateBounceCount : ""),
|
|
1417
|
-
label: "Recording " + step.name + " result"
|
|
1418
|
-
schema: { type: "object" }
|
|
2161
|
+
label: "Recording " + step.name + " result"
|
|
1419
2162
|
}
|
|
1420
2163
|
);
|
|
1421
2164
|
|
|
@@ -1441,17 +2184,33 @@ while (i < STEPS.length) {
|
|
|
1441
2184
|
return { status: "failed", task_id: taskId, reason: "Publish failed: " + summary };
|
|
1442
2185
|
}
|
|
1443
2186
|
|
|
2187
|
+
// Publish verification park: the build landed and post-deploy finalized,
|
|
2188
|
+
// but provenance is UNSTAMPED until the parent's independent read-back
|
|
2189
|
+
// (docs/publish-verification.md) confirms the artifact's actual content
|
|
2190
|
+
// matches the merged diff. The parent stamps provenance, then re-queues;
|
|
2191
|
+
// the dispatcher resumes at QA, whose provenance check enforces the stamp
|
|
2192
|
+
// mechanically. A failed Publish never reaches this park — it returned
|
|
2193
|
+
// failed above and retries under the dispatcher's cap. The merge lock is
|
|
2194
|
+
// already released (post-deploy), so the parked task holds no resources.
|
|
2195
|
+
if (passed && step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG && !publishSkippedNoLock && publishBuildLanded) {
|
|
2196
|
+
return await parkTask("publish: verification-requested " + mergeCommitForPublish +
|
|
2197
|
+
" (build " + (rebuildAgentId || "agent_id unobserved") + ")" +
|
|
2198
|
+
" — artifact build landed, post-deploy finalized, provenance NOT stamped. Parent: run docs/publish-verification.md" +
|
|
2199
|
+
(publishVerifyInspect.triggered
|
|
2200
|
+
? " (content read-back inspection " + publishVerifyInspect.inspection_id + " already triggered)."
|
|
2201
|
+
: " (read-back inspect trigger failed: " + (publishVerifyInspect.error || "not started") + " — parent: trigger artifact_inspect manually)."));
|
|
2202
|
+
}
|
|
2203
|
+
|
|
1444
2204
|
i++;
|
|
1445
2205
|
}
|
|
1446
2206
|
|
|
1447
2207
|
await agent(
|
|
1448
2208
|
"Mark this task as done.\n" +
|
|
1449
|
-
"
|
|
1450
|
-
"Then
|
|
1451
|
-
|
|
1452
|
-
{ key: "task-done", label: "Completing task: " + taskTitle, schema: { type: "object" } }
|
|
2209
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("update-task", { id: taskId, state: "done" }) + "\n" +
|
|
2210
|
+
"Then run in shell and return the stdout verbatim:\n" + crewCmd("log-event", { task_id: taskId, type: "completed", message: "All chore workflow steps complete." }),
|
|
2211
|
+
{ key: "task-done", label: "Completing task: " + taskTitle }
|
|
1453
2212
|
);
|
|
1454
2213
|
|
|
1455
2214
|
log("Chore workflow complete for task " + taskId);
|
|
1456
|
-
await agent("Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB, { key: "cleanup-pins", label: "Cleaning pinned scripts"
|
|
2215
|
+
await agent("Clean up pinned lifecycle scripts: rm -rf " + RUN_LIB, { key: "cleanup-pins", label: "Cleaning pinned scripts" });
|
|
1457
2216
|
return { status: "ok", task_id: taskId, message: "Chore workflow complete for " + taskTitle };
|