muse-crew 0.17.2 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +11 -0
- package/docs/decisions/composition-machinery.md +180 -0
- package/docs/decisions/publish-path.md +6 -0
- package/docs/decisions/workflow-core.md +5 -4
- package/lib/AGENTS.md +2 -1
- package/lib/bugfix/phases/build.js +168 -0
- package/lib/bugfix/phases/capture.js +170 -0
- package/lib/bugfix/phases/integrate.js +165 -0
- package/lib/bugfix/phases/map.js +129 -0
- package/lib/bugfix/phases/publish.js +592 -0
- package/lib/bugfix/phases/qa.js +356 -0
- package/lib/bugfix/phases/reproduce.js +254 -0
- package/lib/bugfix/phases/review.js +292 -0
- package/lib/bugfix/phases/triage.js +69 -0
- package/lib/chore/CONTRACT.md +181 -0
- package/lib/chore/DISPOSITION.md +98 -0
- package/lib/chore/extract.js +204 -0
- package/lib/chore/phase-lib.js +885 -0
- package/lib/chore/phases/build.js +110 -0
- package/lib/chore/phases/capture.js +82 -0
- package/lib/chore/phases/integrate.js +109 -0
- package/lib/chore/phases/map.js +81 -0
- package/lib/chore/phases/publish.js +543 -0
- package/lib/chore/phases/review.js +255 -0
- package/lib/chore/phases/triage.js +57 -0
- package/lib/chore/prompts/evidence-gatherer.js +41 -0
- package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
- package/lib/chore/prompts/tool-check.js +15 -0
- package/lib/chore/prompts/trailers.js +56 -0
- package/lib/chore/prompts/verdict-reask.js +28 -0
- package/lib/chore/prompts/verdict-reask.schema.json +1 -0
- package/lib/chore/prompts/work-agent.js +52 -0
- package/lib/chore/prompts/work-agent.schema.json +1 -0
- package/lib/chore/spawn-keys.js +44 -0
- package/lib/chore/spawn-vocab.js +87 -0
- package/lib/chore-run.js +538 -0
- package/lib/chore-tick.js +289 -0
- package/lib/crew-api.js +273 -0
- package/lib/crew-dispatch-worker.js +27 -7
- package/lib/crew-release.sh +7 -2
- package/lib/extract.js +252 -0
- package/lib/prompts/tool-check.js +18 -0
- package/lib/prompts/trailers.js +59 -0
- package/lib/prompts/verdict-reask.js +31 -0
- package/lib/prompts/verdict-reask.schema.json +1 -0
- package/lib/prompts/work-agent.js +56 -0
- package/lib/prompts/work-agent.schema.json +1 -0
- package/lib/reap-spawns.js +407 -0
- package/lib/schema.sql +12 -1
- package/lib/spawn-keys.js +47 -0
- package/lib/spawn-step.js +572 -0
- package/lib/standard/phases/build.js +120 -0
- package/lib/standard/phases/capture.js +163 -0
- package/lib/standard/phases/integrate.js +172 -0
- package/lib/standard/phases/map.js +119 -0
- package/lib/standard/phases/publish.js +565 -0
- package/lib/standard/phases/qa.js +399 -0
- package/lib/standard/phases/review.js +281 -0
- package/lib/standard/phases/triage.js +64 -0
- package/lib/test-detached-integrate.sh +47 -0
- package/lib/workflow-driver.js +605 -0
- package/lib/workflow-lib.js +1012 -0
- package/lib/workflow-spec.js +187 -0
- package/lib/worktree-lifecycle.sh +55 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +61 -9
- package/workflows/bugfix.js +17 -17
- package/workflows/chore.js +16 -16
- package/workflows/docs.js +14 -11
- package/workflows/standard.js +16 -16
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
// lib/chore/phase-lib.js — shared deterministic operations for the
|
|
2
|
+
// worker-layer chore phase modules (sandbox exit, Piece 2).
|
|
3
|
+
//
|
|
4
|
+
// Import-safe: no side effects on import, bare `node` exits 0. All I/O goes
|
|
5
|
+
// through execFile with argv only (never shell, never bare exec) or through
|
|
6
|
+
// the pinned crew-api CLI. No creative-boundary calls anywhere in this file — the only
|
|
7
|
+
// crossings are NEED_SPAWN results returned to the driver.
|
|
8
|
+
//
|
|
9
|
+
// The ctx contract (see lib/chore/CONTRACT.md):
|
|
10
|
+
// ctx.env — static run config (paths, task facts, project config)
|
|
11
|
+
// ctx.state — derived per-pass scalars (phase, session, counters, handoffs)
|
|
12
|
+
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import {
|
|
15
|
+
readFileSync, writeFileSync, appendFileSync, mkdirSync,
|
|
16
|
+
copyFileSync, chmodSync, existsSync, statSync, readlinkSync, readdirSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join, dirname, basename } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import {
|
|
21
|
+
extractVerdict, extractMarkerLines, parseToolSignals,
|
|
22
|
+
extractReleaseDecision, extractWorktree, extractExperiential,
|
|
23
|
+
} from "./extract.js";
|
|
24
|
+
import { buildTransportRetryTrailer } from "./prompts/trailers.js";
|
|
25
|
+
import { workKeyBase, workRetryKey, verdictReaskKey, attemptKey } from "./spawn-keys.js";
|
|
26
|
+
|
|
27
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
export const PROMPTS_DIR = join(LIB_DIR, "prompts");
|
|
29
|
+
|
|
30
|
+
// Workflow log line. Phase modules prefix with their step name, mirroring
|
|
31
|
+
// the source's log() call sites.
|
|
32
|
+
export function log(message) {
|
|
33
|
+
console.log(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── argv-only command runner ────────────────────────────────────────────
|
|
37
|
+
// execFile, never shell. Rejects with an Error carrying .stdout/.stderr/.code.
|
|
38
|
+
export function runCmd(argv, opts) {
|
|
39
|
+
var o = opts || {};
|
|
40
|
+
return new Promise(function (resolve, reject) {
|
|
41
|
+
execFile(argv[0], argv.slice(1), {
|
|
42
|
+
cwd: o.cwd,
|
|
43
|
+
env: o.env,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
46
|
+
timeout: o.timeout_ms || 300000,
|
|
47
|
+
}, function (err, stdout, stderr) {
|
|
48
|
+
if (err) {
|
|
49
|
+
var e = new Error(
|
|
50
|
+
"command failed: " + argv[0] + " " + argv.slice(1).join(" ") +
|
|
51
|
+
" (code " + err.code + (err.killed ? ", killed" : "") + "): " +
|
|
52
|
+
String(stderr || stdout || err.message).slice(0, 500)
|
|
53
|
+
);
|
|
54
|
+
e.stdout = String(stdout || "");
|
|
55
|
+
e.stderr = String(stderr || "");
|
|
56
|
+
e.code = err.code;
|
|
57
|
+
reject(e);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Pinned crew-api caller ──────────────────────────────────────────────
|
|
66
|
+
// Mirrors spawn-step.js callCrewApi: node <pinned-api> --crew-home <home>
|
|
67
|
+
// <command> --json '<args>'. Throws with the CLI's stderr on failure.
|
|
68
|
+
export async function crewApi(env, command, args) {
|
|
69
|
+
var argv = ["node", env.crewApiPinned, "--crew-home", env.crewHome, command];
|
|
70
|
+
if (args !== undefined) argv.push("--json", JSON.stringify(args));
|
|
71
|
+
var out;
|
|
72
|
+
try {
|
|
73
|
+
out = await runCmd(argv);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Error("crew-api " + command + " failed: " + (e.stderr || e.message).slice(0, 500));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(out.stdout);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw new Error("crew-api " + command + " returned unparseable JSON");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Pinned script runners ───────────────────────────────────────────────
|
|
85
|
+
// The lifecycle and merge-lock scripts take CREW_HOME/CREW_REPO in the
|
|
86
|
+
// environment (LIFECYCLE_ENV in the source). argv only, never shell.
|
|
87
|
+
function scriptEnv(env) {
|
|
88
|
+
return Object.assign({}, process.env, {
|
|
89
|
+
CREW_HOME: env.crewHome,
|
|
90
|
+
CREW_REPO: env.repoPath,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function lifecycle(env, subcommand, args) {
|
|
95
|
+
return runCmd([env.lifecycle, subcommand].concat(args || []), { env: scriptEnv(env) });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mergeLock(env, subcommand, args) {
|
|
99
|
+
return runCmd([env.mergeLock, subcommand].concat(args || []), { env: scriptEnv(env) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Crew API conveniences ───────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export async function logEvent(env, entry) {
|
|
105
|
+
return crewApi(env, "log-event", entry);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function recordPhase(env, payload) {
|
|
109
|
+
return crewApi(env, "record-phase", payload);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function getEvents(env, taskId, limit) {
|
|
113
|
+
var args = { task_id: taskId };
|
|
114
|
+
if (limit !== undefined) args.limit = limit;
|
|
115
|
+
var res = await crewApi(env, "get-events", args);
|
|
116
|
+
return (res && res.events) || [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function getSpawnRow(env, key) {
|
|
120
|
+
try {
|
|
121
|
+
var res = await crewApi(env, "get-spawn-row", { spawn_key: key });
|
|
122
|
+
return (res && res.row) || null;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (/not found/i.test(e.message)) return null;
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Pin lifecycle scripts (pure Node — no shell, no agent) ─────────────
|
|
130
|
+
// Verbatim port of the pinLifecycle courier's shell: mkdir -p RUN_LIB,
|
|
131
|
+
// copy the 11 pinned scripts, chmod +x the three executables. Returns the
|
|
132
|
+
// basenames present (parsePinListing of an ls -1 equivalent).
|
|
133
|
+
const PIN_FILES = [
|
|
134
|
+
["lib/worktree-lifecycle.sh", "worktree-lifecycle.sh", true],
|
|
135
|
+
["lib/merge-lock.sh", "merge-lock.sh", true],
|
|
136
|
+
["lib/publish-npm.sh", "publish-npm.sh", true],
|
|
137
|
+
["current/lib/crew-api.js", "crew-api.js", false],
|
|
138
|
+
["lib/schema.sql", "schema.sql", false],
|
|
139
|
+
["current/lib/compute-publish-diff.js", "compute-publish-diff.js", false],
|
|
140
|
+
["current/lib/classify-surface.js", "classify-surface.js", false],
|
|
141
|
+
["current/lib/publish-note-vocabulary.js", "publish-note-vocabulary.js", false],
|
|
142
|
+
["current/lib/qa-deploy.mjs", "qa-deploy.mjs", false],
|
|
143
|
+
["current/lib/serve-artifact.js", "serve-artifact.js", false],
|
|
144
|
+
["current/lib/qa-db.js", "qa-db.js", false],
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
export const PIN_BASENAMES = PIN_FILES.map(function (f) { return f[1]; });
|
|
148
|
+
|
|
149
|
+
export function pinLifecycle(env) {
|
|
150
|
+
mkdirSync(env.runLib, { recursive: true });
|
|
151
|
+
var present = [];
|
|
152
|
+
for (var i = 0; i < PIN_FILES.length; i++) {
|
|
153
|
+
var src = join(env.crewHome, PIN_FILES[i][0]);
|
|
154
|
+
var dst = join(env.runLib, PIN_FILES[i][1]);
|
|
155
|
+
copyFileSync(src, dst);
|
|
156
|
+
if (PIN_FILES[i][2]) chmodSync(dst, 0o755);
|
|
157
|
+
present.push(PIN_FILES[i][1]);
|
|
158
|
+
}
|
|
159
|
+
return present;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// parsePinListing — basenames from a pin/verify listing. Verbatim from
|
|
163
|
+
// workflows/chore.js (byte-identical across standard/bugfix/chore).
|
|
164
|
+
export function parsePinListing(result) {
|
|
165
|
+
return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Spawn-boundary plumbing ─────────────────────────────────────────────
|
|
169
|
+
// Bounds for a creative-spawn pre request. owner_tick_seq is filled by the
|
|
170
|
+
// driver (the only writer that knows the tick).
|
|
171
|
+
export function buildSpawnBounds(env, kind) {
|
|
172
|
+
return {
|
|
173
|
+
prompt_file: join(PROMPTS_DIR, kind === "work-agent" ? "work-agent.js" : kind + ".js"),
|
|
174
|
+
schema_file: join(PROMPTS_DIR, kind + ".schema.json"),
|
|
175
|
+
cwd: env.repoPath,
|
|
176
|
+
env: {},
|
|
177
|
+
workdir: env.repoPath,
|
|
178
|
+
crewHome: env.crewHome,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// NEED_SPAWN result. The driver fills owner_tick_seq and posts the pre
|
|
183
|
+
// request to the spawn bridge; the phase resumes on the next pass.
|
|
184
|
+
export function needSpawn(request) {
|
|
185
|
+
return { type: "NEED_SPAWN", request: request };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// workRequest — a work-agent pre request for the boundary. Every prompt
|
|
189
|
+
// param is declared (P1); the trailer is a pure function of the prior
|
|
190
|
+
// transport failure.
|
|
191
|
+
export function workRequest(env, state, spec) {
|
|
192
|
+
var trailer = spec.trailer || "";
|
|
193
|
+
return {
|
|
194
|
+
key: spec.key,
|
|
195
|
+
kind: "work-agent",
|
|
196
|
+
task_id: env.taskId,
|
|
197
|
+
phase: spec.phase,
|
|
198
|
+
identity: spec.identity,
|
|
199
|
+
timeout_ms: 3600000,
|
|
200
|
+
bounds: buildSpawnBounds(env, "work-agent"),
|
|
201
|
+
prompt_params: {
|
|
202
|
+
identity: spec.identity,
|
|
203
|
+
orch_path: env.orchPath,
|
|
204
|
+
task_title: env.taskTitle,
|
|
205
|
+
task_description: env.taskDescription,
|
|
206
|
+
task_id: env.taskId,
|
|
207
|
+
step_name: spec.phase,
|
|
208
|
+
instructions: spec.instructions,
|
|
209
|
+
crew_api: spec.crewApiLine === false ? "" : env.crewApiPinned,
|
|
210
|
+
event_preamble: spec.eventPreamble,
|
|
211
|
+
transport_retry_trailer: trailer,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// reaskRequest — a verdict-reask pre request. Always reads from the work
|
|
217
|
+
// report's own content (never copies a VERDICT line).
|
|
218
|
+
export function reaskRequest(env, state, spec) {
|
|
219
|
+
return {
|
|
220
|
+
key: spec.key,
|
|
221
|
+
kind: "verdict-reask",
|
|
222
|
+
task_id: env.taskId,
|
|
223
|
+
phase: spec.phase,
|
|
224
|
+
identity: "verdict-reask",
|
|
225
|
+
timeout_ms: 180000,
|
|
226
|
+
bounds: buildSpawnBounds(env, "verdict-reask"),
|
|
227
|
+
prompt_params: {
|
|
228
|
+
step_name: spec.phase,
|
|
229
|
+
worker_text: spec.workerText,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Spawn session text ──────────────────────────────────────────────────
|
|
235
|
+
// Reads the pinned session file the bridge wrote for a completed spawn.
|
|
236
|
+
// Returns null on any read/parse failure (treated as unusable output).
|
|
237
|
+
export function readSessionText(env, key) {
|
|
238
|
+
try {
|
|
239
|
+
var raw = readFileSync(join(env.crewHome, ".spawn-sessions", key + ".json"), "utf8");
|
|
240
|
+
var obj = JSON.parse(raw);
|
|
241
|
+
return typeof obj.text === "string" ? obj.text : null;
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Boundary internals ──────────────────────────────────────────────────
|
|
248
|
+
// Terminal rows are the bridge's closed vocabulary (lib/crew-api.js
|
|
249
|
+
// CLOSED_OUTCOMES): completed, synthetic, transport_timeout, transport_error,
|
|
250
|
+
// transport_empty, transport_parse, killed_confirmed, killed_by_reaper,
|
|
251
|
+
// orphaned_unkillable, orphaned_unverified, transport_failed. A row whose
|
|
252
|
+
// status is still "running" is not terminal — the boundary stands by.
|
|
253
|
+
function isTerminalRow(row) {
|
|
254
|
+
return !!row && row.status !== "running";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// classifyRow — maps a terminal spawn-ledger row to the source work loop's
|
|
258
|
+
// attempt lanes. Returns one of:
|
|
259
|
+
// {lane: "report", text} completed with usable session text
|
|
260
|
+
// {lane: "no-tools"} report says artifact_tools: missing
|
|
261
|
+
// {lane: "no-transport"} report says shell_transport: unavailable
|
|
262
|
+
// {lane: "empty"} no usable text (blank/transport_empty)
|
|
263
|
+
// {lane: "threw", error} transport_error / kills / other failures
|
|
264
|
+
function classifyRow(env, key, row) {
|
|
265
|
+
// The outcome lives in the result JSON's closed vocabulary (there is no
|
|
266
|
+
// outcome column — record-spawn-close stores it inside result).
|
|
267
|
+
var outcome = null;
|
|
268
|
+
try {
|
|
269
|
+
var res = row.result ? JSON.parse(row.result) : null;
|
|
270
|
+
outcome = (res && typeof res.outcome === "string") ? res.outcome : null;
|
|
271
|
+
} catch (e) { /* keep null */ }
|
|
272
|
+
if (outcome === "completed" || outcome === "synthetic") {
|
|
273
|
+
var text = readSessionText(env, key);
|
|
274
|
+
if (text === null || text.trim().length === 0) return { lane: "empty" };
|
|
275
|
+
var signals = parseToolSignals(text);
|
|
276
|
+
if (signals.artifactTools === "missing") return { lane: "no-tools", text: text };
|
|
277
|
+
if (signals.shellTransport === "unavailable") return { lane: "no-transport", text: text };
|
|
278
|
+
return { lane: "report", text: text };
|
|
279
|
+
}
|
|
280
|
+
if (outcome === "transport_empty") return { lane: "empty" };
|
|
281
|
+
var errDetail = "";
|
|
282
|
+
try {
|
|
283
|
+
var res2 = row.result ? JSON.parse(row.result) : null;
|
|
284
|
+
errDetail = (res2 && res2.error) || "";
|
|
285
|
+
} catch (e) { /* keep empty */ }
|
|
286
|
+
if (outcome === "transport_error") {
|
|
287
|
+
return { lane: "threw", error: String(errDetail).replace(/"/g, "'").slice(0, 160) };
|
|
288
|
+
}
|
|
289
|
+
// Kills, timeouts, orphans, and unknown terminal outcomes are transport
|
|
290
|
+
// failures the source loop would have seen as a thrown worker call.
|
|
291
|
+
return { lane: "threw", error: String(outcome + (errDetail ? ": " + errDetail : "")).replace(/"/g, "'").slice(0, 160) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// describeWorkAgentFailure — honest classification of a work-agent call that
|
|
295
|
+
// yielded no usable report, with the per-attempt evidence preserved in the
|
|
296
|
+
// session notes. Verbatim from workflows/chore.js (returns
|
|
297
|
+
// {notes, eventMessage, blockedReason, message}).
|
|
298
|
+
export function describeWorkAgentFailure(stepName, identity, attempts) {
|
|
299
|
+
var parts = [];
|
|
300
|
+
for (var i = 0; i < attempts.length; i++) {
|
|
301
|
+
var a = attempts[i];
|
|
302
|
+
parts.push("attempt " + (i + 1) + "/" + attempts.length + ": " +
|
|
303
|
+
(a.threw ? "threw '" + a.error + "'" : "returned " + a.outcome));
|
|
304
|
+
}
|
|
305
|
+
var detail = parts.join("; ").replace(/"/g, "'").slice(0, 400);
|
|
306
|
+
var anyThrow = false;
|
|
307
|
+
for (var j = 0; j < attempts.length; j++) {
|
|
308
|
+
if (attempts[j].threw) { anyThrow = true; break; }
|
|
309
|
+
}
|
|
310
|
+
if (anyThrow) {
|
|
311
|
+
return {
|
|
312
|
+
notes: "Work agent produced no machine-readable report after " + attempts.length + " attempts; runtime discarded the output (" + detail + ")",
|
|
313
|
+
eventMessage: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
314
|
+
blockedReason: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts",
|
|
315
|
+
message: "The " + identity + " agent's output could not be machine-read (" + attempts.length + " attempts exhausted); the runtime discarded the raw output before the workflow could see it. Surviving evidence: " + detail
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
notes: "Work agent returned no usable output after " + attempts.length + " attempts (" + detail + ")",
|
|
320
|
+
eventMessage: stepName + " work agent returned no usable output after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
321
|
+
blockedReason: stepName + " work agent returned no usable output",
|
|
322
|
+
message: "The " + identity + " agent returned no usable output after " + attempts.length + " attempts (" + detail + ")."
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// buildFailureAttempts — source-shaped workAttempts entries from the terminal
|
|
327
|
+
// ledger rows, for describeWorkAgentFailure.
|
|
328
|
+
export async function buildFailureAttempts(env, state, phase) {
|
|
329
|
+
var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
|
|
330
|
+
var keys = [
|
|
331
|
+
workKeyBase(env.taskId, phase, state.reworkCount),
|
|
332
|
+
workRetryKey(env.taskId, phase, suffix, 1),
|
|
333
|
+
workRetryKey(env.taskId, phase, suffix, 2),
|
|
334
|
+
];
|
|
335
|
+
var attempts = [];
|
|
336
|
+
for (var i = 0; i < keys.length; i++) {
|
|
337
|
+
var row = await getSpawnRow(env, keys[i]);
|
|
338
|
+
if (!row || !isTerminalRow(row)) continue;
|
|
339
|
+
var c = classifyRow(env, keys[i], row);
|
|
340
|
+
if (c.lane === "report") continue;
|
|
341
|
+
if (c.lane === "threw") {
|
|
342
|
+
attempts.push({ threw: true, error: c.error, outcome: "" });
|
|
343
|
+
} else if (c.lane === "empty") {
|
|
344
|
+
attempts.push({ threw: false, error: "", outcome: "blank string" });
|
|
345
|
+
} else if (c.lane === "no-tools") {
|
|
346
|
+
attempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
|
|
347
|
+
} else if (c.lane === "no-transport") {
|
|
348
|
+
attempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (attempts.length === 0) {
|
|
352
|
+
attempts.push({ threw: false, error: "", outcome: "no record" });
|
|
353
|
+
}
|
|
354
|
+
return attempts;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── The shared creative boundary ────────────────────────────────────────
|
|
358
|
+
// Verbatim port of the source phase work loop (work-agent attempts,
|
|
359
|
+
// transport-retry trailers, deterministic verdict extraction, bounded
|
|
360
|
+
// verdict re-ask). It consumes terminal spawn-ledger rows instead of
|
|
361
|
+
// in-process worker calls; it never spawns itself. The driver acts on
|
|
362
|
+
// NEED_SPAWN/STANDBY and re-invokes the phase on the next pass.
|
|
363
|
+
// spec: {phase, identity, instructions, eventPreamble, crewApiLine}
|
|
364
|
+
// Returns:
|
|
365
|
+
// {type: "NEED_SPAWN", request} — post this pre via the spawn bridge
|
|
366
|
+
// {type: "STANDBY", reason} — a spawn is still running; wait
|
|
367
|
+
// {type: "BOUNDARY_DONE", workerText, verdictPassed}
|
|
368
|
+
// {type: "FAILED", reason, detail} — failure recorded; the run stops
|
|
369
|
+
export async function runWorkBoundary(env, state, spec) {
|
|
370
|
+
var phase = spec.phase, identity = spec.identity;
|
|
371
|
+
var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
|
|
372
|
+
var workKeys = [
|
|
373
|
+
workKeyBase(env.taskId, phase, state.reworkCount),
|
|
374
|
+
workRetryKey(env.taskId, phase, suffix, 1),
|
|
375
|
+
workRetryKey(env.taskId, phase, suffix, 2),
|
|
376
|
+
];
|
|
377
|
+
var attempts = [];
|
|
378
|
+
var workerText = null;
|
|
379
|
+
for (var i = 0; i < workKeys.length; i++) {
|
|
380
|
+
var row = await getSpawnRow(env, workKeys[i]);
|
|
381
|
+
if (!row) {
|
|
382
|
+
// The driver has not posted this attempt yet. Attempt 0 carries no
|
|
383
|
+
// trailer; retries carry the trailer derived from the previous
|
|
384
|
+
// attempt's failure mode (source: retryReason).
|
|
385
|
+
var trailer = "";
|
|
386
|
+
if (i > 0) {
|
|
387
|
+
var prev = attempts[i - 1];
|
|
388
|
+
var retryReason = prev.threw ? "discarded"
|
|
389
|
+
: prev.outcome === "missing-artifact-tools" ? "no-tools"
|
|
390
|
+
: prev.outcome === "unavailable-shell-transport" ? "no-transport"
|
|
391
|
+
: "empty";
|
|
392
|
+
trailer = buildTransportRetryTrailer(phase, env.repoPath, env.taskId, i, retryReason);
|
|
393
|
+
log(phase + " work agent transport retry " + i + " of 2 — requesting spawn " + workKeys[i]);
|
|
394
|
+
}
|
|
395
|
+
return needSpawn(workRequest(env, state, {
|
|
396
|
+
key: workKeys[i], phase: phase, identity: identity,
|
|
397
|
+
instructions: spec.instructions, eventPreamble: spec.eventPreamble,
|
|
398
|
+
crewApiLine: spec.crewApiLine, trailer: trailer,
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
if (!isTerminalRow(row)) {
|
|
402
|
+
return { type: "STANDBY", reason: phase + " work attempt " + (i + 1) + " of 3 still running (" + workKeys[i] + ")", key: workKeys[i] };
|
|
403
|
+
}
|
|
404
|
+
var c = classifyRow(env, workKeys[i], row);
|
|
405
|
+
if (c.lane === "report") {
|
|
406
|
+
if (i > 0) log(phase + " work agent transport retry " + i + " returned a machine-readable report");
|
|
407
|
+
workerText = c.text;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
if (c.lane === "threw") {
|
|
411
|
+
attempts.push({ threw: true, error: c.error, outcome: "" });
|
|
412
|
+
log(phase + " work agent attempt " + (i + 1) + " of 3 threw: " + c.error);
|
|
413
|
+
} else if (c.lane === "empty") {
|
|
414
|
+
attempts.push({ threw: false, error: "", outcome: "blank string" });
|
|
415
|
+
log(phase + " work agent attempt " + (i + 1) + " of 3 returned no usable output (blank string) — retrying with a fresh key");
|
|
416
|
+
} else if (c.lane === "no-tools") {
|
|
417
|
+
attempts.push({ threw: false, error: "", outcome: "missing-artifact-tools" });
|
|
418
|
+
log(phase + " work agent attempt " + (i + 1) + " of 3 reported artifact_tools: missing — retrying with a fresh launch");
|
|
419
|
+
} else {
|
|
420
|
+
attempts.push({ threw: false, error: "", outcome: "unavailable-shell-transport" });
|
|
421
|
+
log(phase + " work agent attempt " + (i + 1) + " of 3 reported shell_transport: unavailable — retrying with a fresh launch");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (typeof workerText !== "string" || !workerText.trim()) {
|
|
426
|
+
// All three attempts exhausted with no usable report — the phase fails
|
|
427
|
+
// for dispatcher retry (session "failed", NOT "blocked": blocked
|
|
428
|
+
// sessions are never picked up again).
|
|
429
|
+
var failure = describeWorkAgentFailure(phase, identity, attempts);
|
|
430
|
+
var notes = failure.notes + " If the step's work is actually complete, the task can be re-launched from " + phase + ".";
|
|
431
|
+
log(phase + " " + notes + " — marking failed for retry");
|
|
432
|
+
await recordPhase(env, {
|
|
433
|
+
task_id: env.taskId,
|
|
434
|
+
session: { id: state.activeSessionId, task_id: env.taskId, identity: identity, step: phase, status: "failed", notes: notes },
|
|
435
|
+
event: { task_id: env.taskId, type: "failed", message: failure.eventMessage },
|
|
436
|
+
});
|
|
437
|
+
return { type: "FAILED", reason: failure.blockedReason, detail: failure.message };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Verdict derivation (deterministic): for VERDICT_STEPS (Build, Review,
|
|
441
|
+
// Integrate, Publish) the workflow owns the verdict — extracted by regex
|
|
442
|
+
// from the report text, never by an agent. A missing/malformed verdict
|
|
443
|
+
// gets a bounded mechanical re-ask before the phase fails closed. For
|
|
444
|
+
// non-verdict steps (Triage, Capture, Map) the worker producing output
|
|
445
|
+
// means the step passed.
|
|
446
|
+
var verdictPassed = null;
|
|
447
|
+
if (spec.verdictStep) {
|
|
448
|
+
var verdict = extractVerdict(workerText);
|
|
449
|
+
if (!verdict.ok) {
|
|
450
|
+
log(phase + " verdict line missing or ambiguous (" + verdict.count + " trailing-window matches) — attempting bounded re-ask");
|
|
451
|
+
var reasked = await runVerdictReask(env, state, phase, workerText);
|
|
452
|
+
if (reasked.type === "NEED_SPAWN" || reasked.type === "STANDBY") return reasked;
|
|
453
|
+
if (reasked.ok) {
|
|
454
|
+
verdict = reasked.verdict;
|
|
455
|
+
} else {
|
|
456
|
+
log(phase + " verdict re-ask exhausted — marking failed for retry");
|
|
457
|
+
await recordPhase(env, {
|
|
458
|
+
task_id: env.taskId,
|
|
459
|
+
session: { id: state.activeSessionId, task_id: env.taskId, identity: identity, step: phase, status: "failed", notes: "Worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)" },
|
|
460
|
+
event: { task_id: env.taskId, type: "failed", message: phase + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry" },
|
|
461
|
+
// Room #26 blocker 33: INDETERMINATE verdict record — the report
|
|
462
|
+
// could not be read at all, but its full text is still preserved
|
|
463
|
+
// as grounds. Review-scoped; other verdict steps keep the
|
|
464
|
+
// existing failed-session behavior with no verdict row.
|
|
465
|
+
verdict: (phase === "Review" ? {
|
|
466
|
+
step: "Review",
|
|
467
|
+
attempt: state.reworkCount,
|
|
468
|
+
reviewer: identity,
|
|
469
|
+
verdict: "INDETERMINATE",
|
|
470
|
+
grounds: workerText,
|
|
471
|
+
} : null),
|
|
472
|
+
});
|
|
473
|
+
return {
|
|
474
|
+
type: "FAILED",
|
|
475
|
+
reason: phase + " worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)",
|
|
476
|
+
detail: "The " + identity + " agent's work may be valid — its report did not declare a verdict the workflow could read, and two bounded re-ask attempts could not transcribe one. The report is preserved in the workflow log.",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
verdictPassed = verdict.passed;
|
|
481
|
+
}
|
|
482
|
+
return { type: "BOUNDARY_DONE", workerText: workerText, verdictPassed: verdictPassed };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// runVerdictReask — the bounded mechanical verdict re-ask (source:
|
|
486
|
+
// reaskVerdict). The re-ask judges the report's own content; it never
|
|
487
|
+
// copies a VERDICT line. Consumes the two verdict-reask keys' terminal
|
|
488
|
+
// rows. Returns {type:"NEED_SPAWN"|"STANDBY"} for the driver, or
|
|
489
|
+
// {ok: true, verdict} / {ok: false} once the budget is resolved.
|
|
490
|
+
export async function runVerdictReask(env, state, phase, workerText) {
|
|
491
|
+
var suffix = state.reworkCount > 0 ? "-r" + state.reworkCount : "";
|
|
492
|
+
for (var attempt = 1; attempt <= 2; attempt++) {
|
|
493
|
+
var key = verdictReaskKey(env.taskId, phase, suffix, attempt);
|
|
494
|
+
var row = await getSpawnRow(env, key);
|
|
495
|
+
if (!row) {
|
|
496
|
+
return needSpawn(reaskRequest(env, state, { key: key, phase: phase, workerText: workerText }));
|
|
497
|
+
}
|
|
498
|
+
if (!isTerminalRow(row)) {
|
|
499
|
+
return { type: "STANDBY", reason: phase + " verdict re-ask attempt " + attempt + " of 2 still running (" + key + ")", key: key };
|
|
500
|
+
}
|
|
501
|
+
var text = readSessionText(env, key);
|
|
502
|
+
var verdict = extractVerdict(text || "");
|
|
503
|
+
if (verdict.ok) {
|
|
504
|
+
log(phase + " verdict re-ask attempt " + attempt + " recovered verdict: " + (verdict.passed ? "PASS" : "FAIL"));
|
|
505
|
+
return { ok: true, verdict: verdict };
|
|
506
|
+
}
|
|
507
|
+
log(phase + " verdict re-ask attempt " + attempt + " produced no readable verdict (" + verdict.count + " trailing-window matches)");
|
|
508
|
+
}
|
|
509
|
+
return { ok: false };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Closeout assembly ───────────────────────────────────────────────────
|
|
513
|
+
// summarizeReport — the step-result summary: the full report text (capped
|
|
514
|
+
// at 2000 chars minus marker space) plus the machine-readable marker lines
|
|
515
|
+
// re-attached. Verbatim from the source's stepResult.summary construction.
|
|
516
|
+
// opts: {markerLines} for the Publish npm target-version block (the
|
|
517
|
+
// TARGET_VERSION line plus the skipped:/published: line); the source's
|
|
518
|
+
// exact 2000 - markerLines - workerMarkers - 2 slice is reproduced.
|
|
519
|
+
export function summarizeReport(workerText, opts) {
|
|
520
|
+
var workerMarkers = extractMarkerLines(workerText);
|
|
521
|
+
var text = workerText || "Step completed";
|
|
522
|
+
if (opts && opts.markerLines) {
|
|
523
|
+
var markerLines = opts.markerLines;
|
|
524
|
+
return text.slice(0, 2000 - markerLines.length - workerMarkers.length - 2) + "\n" + markerLines + (workerMarkers ? "\n" + workerMarkers : "");
|
|
525
|
+
}
|
|
526
|
+
return text.slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// buildEventPreamble — the CONTEXT preamble the agent runs to fetch its own
|
|
530
|
+
// event history. Review is cold by design and Publish receives its release
|
|
531
|
+
// decision deterministically, so both get "".
|
|
532
|
+
export function buildEventPreamble(env, phase) {
|
|
533
|
+
if (phase === "Review" || phase === "Publish") return "";
|
|
534
|
+
return "CONTEXT: First, fetch this task's event history for background.\n" +
|
|
535
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmdString(env, "get-events", { task_id: env.taskId }) + "\n" +
|
|
536
|
+
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Claim protocol (driver-level) ───────────────────────────────────────
|
|
540
|
+
// claimFirst — the run's first claim: task → in_progress (+ workflow when
|
|
541
|
+
// the dispatcher launched it null), claim, then clear the reservation ONLY
|
|
542
|
+
// when the claim succeeded. Verbatim from the source's CLAIM block.
|
|
543
|
+
export async function claimFirst(env, state, step) {
|
|
544
|
+
var taskId = env.taskId;
|
|
545
|
+
var updateArgs = { id: taskId, state: "in_progress" };
|
|
546
|
+
if (env.workflowWasNull && env.resolvedWorkflow) updateArgs.workflow = env.resolvedWorkflow;
|
|
547
|
+
await crewApi(env, "update-task", updateArgs);
|
|
548
|
+
var claimArgs = {
|
|
549
|
+
task_id: taskId, identity: step.identity, step: step.name,
|
|
550
|
+
notes: step.name + " step started",
|
|
551
|
+
};
|
|
552
|
+
if (state.nextPhaseRouted) claimArgs.expected_next_phase = state.nextPhaseRouted;
|
|
553
|
+
var claim = await crewApi(env, "claim-task", claimArgs);
|
|
554
|
+
if (!claim || !claim.claimed) return { claimed: false };
|
|
555
|
+
await crewApi(env, "clear-reservation", { task_id: taskId });
|
|
556
|
+
return { claimed: true, session_id: claim.session_id };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// claimStep — per-phase claim (no reservation clearing; the first claim
|
|
560
|
+
// consumed it). Verbatim from the source's phase-transition claim block.
|
|
561
|
+
export async function claimStep(env, state, step) {
|
|
562
|
+
var notes = step.name + " step started" + (state.reworkCount > 0 ? " (rework #" + state.reworkCount + ")" : "");
|
|
563
|
+
var claim = await crewApi(env, "claim-task", {
|
|
564
|
+
task_id: env.taskId, identity: step.identity, step: step.name, notes: notes,
|
|
565
|
+
});
|
|
566
|
+
// A lost claim race returns claimed:false (no session_id). That is the
|
|
567
|
+
// claim-as-gate: stand down, never proceed with an undefined session.
|
|
568
|
+
if (claim.claimed === false || !claim.session_id) {
|
|
569
|
+
return { stand_down: true, reason: "claim-task lost the race for " + step.name + " (claimed:false)" };
|
|
570
|
+
}
|
|
571
|
+
return { session_id: claim.session_id };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Project guard (driver-level) ────────────────────────────────────────
|
|
575
|
+
// projectGuard — when the task's project changed mid-run, the run is stale:
|
|
576
|
+
// record the phase as failed and clean up, so the dispatcher re-launches
|
|
577
|
+
// from Build with the new project context. Verbatim from the source.
|
|
578
|
+
export async function projectGuard(env, state, step) {
|
|
579
|
+
if (!env.projectId) return { ok: true };
|
|
580
|
+
var st = await crewApi(env, "get-state", { events_limit: 1 });
|
|
581
|
+
var tasks = (st && st.tasks) || [];
|
|
582
|
+
var task = null;
|
|
583
|
+
for (var i = 0; i < tasks.length; i++) {
|
|
584
|
+
if (tasks[i].id === env.taskId) { task = tasks[i]; break; }
|
|
585
|
+
}
|
|
586
|
+
var currentProject = (task && task.project) ? task.project : env.projectId;
|
|
587
|
+
if (currentProject === env.projectId) return { ok: true };
|
|
588
|
+
var abortMessage = "Task project changed mid-run from '" + env.projectId + "' to '" + currentProject + "' — aborting stale run. The dispatcher will re-launch from " + "Build" + " with the new project context.";
|
|
589
|
+
await recordPhase(env, {
|
|
590
|
+
task_id: env.taskId,
|
|
591
|
+
session: { task_id: env.taskId, identity: step.identity, step: "Build", status: "failed", notes: abortMessage + " Rebuild from the Map session notes in the task's event history." },
|
|
592
|
+
event: { task_id: env.taskId, type: "failed", identity: step.identity, message: abortMessage },
|
|
593
|
+
});
|
|
594
|
+
// The source ran cleanup via the agent with a CLEANUP-output contract;
|
|
595
|
+
// the worker layer runs it directly (argv only) and lets a throw abort
|
|
596
|
+
// the run the same way the source's agent throw would.
|
|
597
|
+
await lifecycle(env, "cleanup", [env.taskId]);
|
|
598
|
+
return { ok: false, reason: abortMessage };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── Cross-cutting reads ─────────────────────────────────────────────────
|
|
602
|
+
// resolveExperiential — "yes" | "no" | "unknown". The driver's per-pass
|
|
603
|
+
// experiential seed (from the Triage typed result) wins; otherwise the
|
|
604
|
+
// Triage session notes are read once per pass and memoized (all three
|
|
605
|
+
// outcomes, so a missing marker never re-fires the lookup).
|
|
606
|
+
export async function resolveExperiential(env, state) {
|
|
607
|
+
if (state.memo.experientialResolved !== undefined) return state.memo.experientialResolved;
|
|
608
|
+
var result;
|
|
609
|
+
if (state.experiential === true) result = "yes";
|
|
610
|
+
else if (state.experiential === false) result = "no";
|
|
611
|
+
else {
|
|
612
|
+
try {
|
|
613
|
+
var st = await crewApi(env, "get-state", { events_limit: 1 });
|
|
614
|
+
var sessions = (st && st.sessions) || [];
|
|
615
|
+
var triage = null;
|
|
616
|
+
for (var i = 0; i < sessions.length; i++) {
|
|
617
|
+
var s = sessions[i];
|
|
618
|
+
if (s.task_id === env.taskId && s.step === "Triage" && s.status === "completed") {
|
|
619
|
+
if (!triage || String(s.started_at || "") > String(triage.started_at || "")) triage = s;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
var marker = extractExperiential((triage && triage.notes) || "");
|
|
623
|
+
result = marker === null ? "unknown" : (marker ? "yes" : "no");
|
|
624
|
+
} catch (e) {
|
|
625
|
+
log("resolveExperiential: crew-api call failed (" + (e && e.message ? e.message : e) + ") — treating as unknown");
|
|
626
|
+
result = "unknown";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
state.memo.experientialResolved = result;
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// baselineStatus — reads the task's note events for the exact protocol
|
|
634
|
+
// prefixes (explicit state, never English matching). Returns
|
|
635
|
+
// {baseline_found, baseline_kind, baseline_refs, requested_count, evidence_count}.
|
|
636
|
+
export async function baselineStatus(env) {
|
|
637
|
+
var result = {
|
|
638
|
+
baseline_found: false, baseline_kind: null, baseline_refs: [],
|
|
639
|
+
requested_count: 0, evidence_count: 0,
|
|
640
|
+
};
|
|
641
|
+
try {
|
|
642
|
+
var events = await getEvents(env, env.taskId);
|
|
643
|
+
var latestBaseline = null;
|
|
644
|
+
for (var i = 0; i < events.length; i++) {
|
|
645
|
+
var msg = String((events[i] && events[i].message) || "");
|
|
646
|
+
if (msg.indexOf("baseline: captured") === 0 || msg.indexOf("baseline: none") === 0) {
|
|
647
|
+
result.evidence_count++;
|
|
648
|
+
if (!latestBaseline) latestBaseline = msg;
|
|
649
|
+
} else if (msg.indexOf("baseline: requested") === 0) {
|
|
650
|
+
result.requested_count++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (latestBaseline) {
|
|
654
|
+
result.baseline_found = true;
|
|
655
|
+
result.baseline_kind = latestBaseline.indexOf("baseline: captured") === 0 ? "captured" : "none";
|
|
656
|
+
var refMatch = /refs:\s*([^\n]+)/.exec(latestBaseline);
|
|
657
|
+
if (refMatch) {
|
|
658
|
+
result.baseline_refs = refMatch[1].split(",").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
log("baselineStatus: crew-api call failed (" + (e && e.message ? e.message : e) + ") — treating as no evidence");
|
|
663
|
+
}
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// deriveReworkCount — cross-tick rework count: the number of "rejected"
|
|
668
|
+
// events on the task. getEvents already returns [] when there are no
|
|
669
|
+
// events, so a fresh task starts at 0 without any fallback here.
|
|
670
|
+
// Derivation failure is a transport failure: this MUST throw, never
|
|
671
|
+
// default — a swallowed error would collide attempt keys (stale
|
|
672
|
+
// spawn-report pickup) and bypass the MAX_REWORK cap.
|
|
673
|
+
export async function deriveReworkCount(env) {
|
|
674
|
+
var events = await getEvents(env, env.taskId, 100);
|
|
675
|
+
var n = 0;
|
|
676
|
+
for (var i = 0; i < events.length; i++) {
|
|
677
|
+
if (events[i] && events[i].type === "rejected") n++;
|
|
678
|
+
}
|
|
679
|
+
return n;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
// ── Terminal cleanup & parking ──────────────────────────────────────────
|
|
684
|
+
// terminalCleanup — the run's last act at every park/fail boundary: two
|
|
685
|
+
// direct attempts at terminal-cleanup, then the backstop log line. The
|
|
686
|
+
// source's agent return-shape nuance is preserved: the second attempt is
|
|
687
|
+
// only skipped when the first returned a non-blank string.
|
|
688
|
+
export async function terminalCleanup(env) {
|
|
689
|
+
var taskId = env.taskId;
|
|
690
|
+
for (var attempt = 0; attempt < 2; attempt++) {
|
|
691
|
+
try {
|
|
692
|
+
var out = await lifecycle(env, "terminal-cleanup", [taskId]);
|
|
693
|
+
if (typeof out.stdout === "string" && out.stdout.trim().length > 0) return;
|
|
694
|
+
} catch (e) {
|
|
695
|
+
log("terminal-cleanup attempt " + (attempt + 1) + " of 2 failed: " + (e && e.message ? e.message : e));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
log("terminal-cleanup backstop: both attempts failed; worktree/branch state may need manual review for task " + taskId);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// parkTask — parks the task for human attention (message capped at 1000
|
|
702
|
+
// chars), then runs terminal cleanup. The driver owns telemetryEnd.
|
|
703
|
+
export async function parkTask(env, reason) {
|
|
704
|
+
var taskId = env.taskId;
|
|
705
|
+
log("Parking task " + taskId + " for human attention: " + reason);
|
|
706
|
+
var parkMessage = ("Parked: " + reason).slice(0, 1000);
|
|
707
|
+
try {
|
|
708
|
+
await crewApi(env, "park-task", { task_id: taskId, message: parkMessage });
|
|
709
|
+
} catch (e) {
|
|
710
|
+
log("PARK FAILED for task " + taskId + ": " + (e && e.message ? e.message : e));
|
|
711
|
+
await terminalCleanup(env);
|
|
712
|
+
return { type: "PARK_FAILED", reason: "park failed: " + reason };
|
|
713
|
+
}
|
|
714
|
+
await terminalCleanup(env);
|
|
715
|
+
return { type: "PARK", reason: reason };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// recordPublishLedger — the append-only publish outcome ledger
|
|
719
|
+
// ($CREW_HOME/.publish-ledger/<publish-slug>.jsonl), ported to direct
|
|
720
|
+
// Node file I/O (the source ran it through a courier agent's shell; the
|
|
721
|
+
// bytes on disk are identical). Field-for-field identical to the source.
|
|
722
|
+
// Non-fatal: a write failure logs and returns false, exactly like the
|
|
723
|
+
// source's catch. P4 is preserved: entry.commit is referenced at the
|
|
724
|
+
// early artifact-publish rows BEFORE its `var` declaration, so it is
|
|
725
|
+
// undefined there and serializes as commit: null.
|
|
726
|
+
export async function recordPublishLedger(env, entry) {
|
|
727
|
+
var taskId = env.taskId;
|
|
728
|
+
try {
|
|
729
|
+
var ledgerDir = join(env.crewHome, ".publish-ledger");
|
|
730
|
+
mkdirSync(ledgerDir, { recursive: true });
|
|
731
|
+
// The source substituted `date -u +%Y-%m-%dT%H:%M:%SZ` via sed; the
|
|
732
|
+
// port runs the same command directly.
|
|
733
|
+
var tsOut = await runCmd(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"]);
|
|
734
|
+
var line = JSON.stringify({
|
|
735
|
+
ts: String(tsOut.stdout || "").trim(),
|
|
736
|
+
task_id: taskId,
|
|
737
|
+
workflow: env.resolvedWorkflow || "unknown",
|
|
738
|
+
slug: env.publishSlug,
|
|
739
|
+
commit: entry.commit || null,
|
|
740
|
+
attempt: entry.attempt || null,
|
|
741
|
+
agent_id: entry.agent_id || null,
|
|
742
|
+
applied_report: entry.applied_report || null,
|
|
743
|
+
manifest_before: entry.manifest_before || null,
|
|
744
|
+
outcome: entry.outcome,
|
|
745
|
+
detail: entry.detail || "",
|
|
746
|
+
// D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
|
|
747
|
+
// instant (null when no trigger); ts = ledger-write instant.
|
|
748
|
+
issued_at: entry.issued_at || null,
|
|
749
|
+
// (2026-09-20, one-party worker-owned publish) issuer = which party
|
|
750
|
+
// wrote the entry ("workflow" for the intent entry, "tick-worker" for
|
|
751
|
+
// the worker's own issuance); diff_path/diff_sha256 locate the
|
|
752
|
+
// checksummed diff the worker issues. The workflow never writes an
|
|
753
|
+
// issuance ("submitted") entry — it did not issue.
|
|
754
|
+
issuer: entry.issuer || null,
|
|
755
|
+
diff_path: entry.diff_path || null,
|
|
756
|
+
diff_sha256: entry.diff_sha256 || null,
|
|
757
|
+
base: entry.base || null,
|
|
758
|
+
// (0.14.6) Numeric publication attempt + the attempt's deterministic
|
|
759
|
+
// version. Attempt 1 is staged here by the workflow; attempts 2-3 are
|
|
760
|
+
// staged by scan-ack-pending's re-issue path with fresh versions.
|
|
761
|
+
publish_attempt: entry.publish_attempt || null,
|
|
762
|
+
version: entry.version || null,
|
|
763
|
+
});
|
|
764
|
+
appendFileSync(join(ledgerDir, env.publishSlug + ".jsonl"), line + "\n");
|
|
765
|
+
log("Noted publish outcome '" + entry.outcome + "' for task " + taskId + " in ledger");
|
|
766
|
+
return true;
|
|
767
|
+
} catch (e) {
|
|
768
|
+
log("Publish ledger: write failed for task " + taskId + " (non-fatal, observability only): " + (e && e.message ? e.message : e));
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Instruction-string helpers ──────────────────────────────────────────
|
|
774
|
+
// crewCmdString — the pinned crew-api CLI invocation for agent
|
|
775
|
+
// instructions. Verbatim port of the source's crewCmd (which used the
|
|
776
|
+
// pinned CREW_API after the pin step).
|
|
777
|
+
export function crewCmdString(env, command, args) {
|
|
778
|
+
var json = JSON.stringify(args || {}).replace(/'/g, "'\\''");
|
|
779
|
+
return "node " + env.crewApiPinned + " --crew-home " + env.crewHome + " " + command + " --json '" + json + "'";
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// lifecycleEnvPrefix — the "CREW_HOME=... CREW_REPO=... " prefix baked
|
|
783
|
+
// into every lifecycle invocation the agents run (source: LIFECYCLE_ENV).
|
|
784
|
+
export function lifecycleEnvPrefix(env) {
|
|
785
|
+
return "CREW_HOME=" + env.crewHome + " CREW_REPO=" + env.repoPath + " ";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// latestSessionNotes — the notes of the latest session for this task with
|
|
789
|
+
// the given step+status ("" when none). Cross-phase handoffs (mapper spec,
|
|
790
|
+
// rejection notes, release decision, repo_diff:none claim) are re-derived
|
|
791
|
+
// from durable session notes so a run survives tick boundaries.
|
|
792
|
+
export async function latestSessionNotes(env, step, status) {
|
|
793
|
+
var st = await crewApi(env, "get-state", { events_limit: 1 });
|
|
794
|
+
var sessions = (st && st.sessions) || [];
|
|
795
|
+
var best = null;
|
|
796
|
+
for (var i = 0; i < sessions.length; i++) {
|
|
797
|
+
var s = sessions[i];
|
|
798
|
+
if (s.task_id === env.taskId && s.step === step && s.status === status) {
|
|
799
|
+
if (!best || String(s.started_at || "") > String(best.started_at || "")) best = s;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return best ? (best.notes || "") : "";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// verifyPin — the pre-Publish pin guard's mechanical check: every
|
|
806
|
+
// PIN_BASENAMES entry present in the run lib.
|
|
807
|
+
export function verifyPin(env) {
|
|
808
|
+
var names;
|
|
809
|
+
try {
|
|
810
|
+
names = readdirSync(env.runLib);
|
|
811
|
+
} catch (e) {
|
|
812
|
+
return { ok: false, missing: PIN_BASENAMES.slice() };
|
|
813
|
+
}
|
|
814
|
+
var missing = PIN_BASENAMES.filter(function (b) { return names.indexOf(b) === -1; });
|
|
815
|
+
return { ok: missing.length === 0, missing: missing };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// deriveRunEnv — the static per-run environment, derived once by the
|
|
819
|
+
// driver from the dispatch inputs and the project config. All surface /
|
|
820
|
+
// path / title derivations are verbatim from workflows/chore.js.
|
|
821
|
+
export function deriveRunEnv(o) {
|
|
822
|
+
var publishType = o.projectConfig.deploy_type || "";
|
|
823
|
+
var envType = o.projectConfig.environment_type || null;
|
|
824
|
+
var surfaceArtifact = (publishType === "artifact" || envType === "artifact");
|
|
825
|
+
var surfaceTerminal = (!surfaceArtifact && envType === "terminal");
|
|
826
|
+
var surfaceTriageDesc = surfaceArtifact
|
|
827
|
+
? "This project's user-facing surface is artifact: a rendered web UI."
|
|
828
|
+
: surfaceTerminal
|
|
829
|
+
? "This project's user-facing surface is terminal: a command-line interface."
|
|
830
|
+
: "This project's user-facing surface is unclassified (environment_type not set): judge by what a user would directly observe.";
|
|
831
|
+
var uxDoctrinePage = surfaceTerminal ? "terminal-ux.md" : (surfaceArtifact ? "artifact-ux.md" : null);
|
|
832
|
+
return {
|
|
833
|
+
crewHome: o.crewHome, repoPath: o.repoPath, orchPath: o.orchPath, runLib: o.runLib,
|
|
834
|
+
lifecycle: o.lifecycle, mergeLock: o.mergeLock, publishNpm: o.publishNpm, computeDiff: o.computeDiff,
|
|
835
|
+
releaseScript: o.crewHome + "/crew-release.sh",
|
|
836
|
+
crewApi: o.crewApi, crewApiPinned: o.crewApiPinned,
|
|
837
|
+
taskId: o.taskId, taskTitle: o.taskTitle, taskDescription: o.taskDescription,
|
|
838
|
+
projectId: o.projectId, publishType: publishType,
|
|
839
|
+
publishSlug: o.projectConfig.deploy_slug || "",
|
|
840
|
+
projectDesc: o.projectConfig.description || "React + TypeScript web dashboard (client/src/, server/src/, drizzle/)",
|
|
841
|
+
surfaceArtifact: surfaceArtifact, surfaceTerminal: surfaceTerminal,
|
|
842
|
+
surfaceTriageDesc: surfaceTriageDesc,
|
|
843
|
+
uxDoctrinePath: uxDoctrinePage ? o.crewHome + "/current/docs/" + uxDoctrinePage : null,
|
|
844
|
+
visualProtocolAvailable: o.visualProtocol === true,
|
|
845
|
+
workflowWasNull: !!o.workflowWasNull, resolvedWorkflow: o.resolvedWorkflow || null,
|
|
846
|
+
worktreeHint: o.repoPath + "/.worktrees/" + o.taskId,
|
|
847
|
+
worktreePreservedHint: ".worktrees/" + o.taskId,
|
|
848
|
+
taskBranch: "task/" + o.taskId,
|
|
849
|
+
safeTitle: String(o.taskTitle || "").replace(/"/g, "'").replace(/\\/g, "\\\\").replace(/`/g, "'"),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// closeoutPassed — the deterministic closeout rule: for verdict steps the
|
|
854
|
+
// verdict decides; for non-verdict steps (Triage, Capture, Map) the worker
|
|
855
|
+
// producing output means the step passed.
|
|
856
|
+
export function closeoutPassed(boundaryResult) {
|
|
857
|
+
return boundaryResult.verdictPassed !== null ? boundaryResult.verdictPassed === true : true;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ensureClaimed — the phase claim protocol. Claims once per phase visit;
|
|
861
|
+
// on resume passes the driver passes the persisted activeSessionId and the
|
|
862
|
+
// claim is skipped. A lost first-claim race stands down quietly.
|
|
863
|
+
export async function ensureClaimed(env, state, phase) {
|
|
864
|
+
if (state.activeSessionId) return { type: "CLAIMED", session_id: state.activeSessionId };
|
|
865
|
+
if (state.isFirstClaimVisit) {
|
|
866
|
+
var first = await claimFirst(env, state, phase);
|
|
867
|
+
if (!first.claimed) {
|
|
868
|
+
log(phase.name + " lost the claim race for task " + env.taskId + " — standing down quietly");
|
|
869
|
+
return { type: "STAND_DOWN", reason: "lost claim race for task " + env.taskId };
|
|
870
|
+
}
|
|
871
|
+
state.activeSessionId = first.session_id;
|
|
872
|
+
// Blocker 46: the first claim happens exactly once per run. Later
|
|
873
|
+
// phases in the same run go through claimStep (a fresh per-phase
|
|
874
|
+
// session), never a second first-claim.
|
|
875
|
+
state.isFirstClaimVisit = false;
|
|
876
|
+
return { type: "CLAIMED", session_id: first.session_id };
|
|
877
|
+
}
|
|
878
|
+
var step = await claimStep(env, state, phase);
|
|
879
|
+
if (step.stand_down) {
|
|
880
|
+
log(phase.name + " lost the claim race for task " + env.taskId + " — standing down quietly");
|
|
881
|
+
return { type: "STAND_DOWN", reason: step.reason };
|
|
882
|
+
}
|
|
883
|
+
state.activeSessionId = step.session_id;
|
|
884
|
+
return { type: "CLAIMED", session_id: step.session_id };
|
|
885
|
+
}
|