muse-crew 0.4.2 → 0.4.4
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 +2 -0
- package/API.md +68 -21
- package/README.md +18 -2
- package/docs/guide.md +81 -11
- package/docs/visual-verdict.md +120 -0
- package/identities/hazel.md +22 -0
- package/lib/AGENTS.md +6 -1
- package/lib/build-registry.js +80 -0
- package/lib/compose-evidence.py +144 -0
- package/lib/crew-release.sh +9 -1
- package/lib/merge-lock.sh +18 -0
- package/lib/orphan-sweep.sh +70 -7
- package/lib/publish-npm.sh +174 -0
- package/lib/test-merge-lock.sh +69 -0
- package/lib/test-orphan-sweep.sh +74 -8
- package/lib/test-version-write.sh +97 -0
- package/lib/test-worktree-backend.sh +79 -0
- package/lib/worktree-lifecycle.sh +69 -28
- package/package.json +1 -1
- package/seed/cron-body-template.md +5 -3
- package/seed/workflows/bugfix.md +7 -2
- package/seed/workflows/chore.md +7 -3
- package/seed/workflows/standard.md +7 -2
- package/workflows/AGENTS.md +4 -4
- package/workflows/bugfix.js +1023 -185
- package/workflows/chore.js +888 -158
- package/workflows/crew-dispatch.js +405 -98
- package/workflows/crew-init.js +4 -35
- package/workflows/docs.js +342 -22
- package/workflows/standard.js +1028 -177
- package/workflows/tests/retry-cap.test.mjs +102 -0
- package/workflows/tests/work-agent-failure.test.mjs +122 -0
package/workflows/crew-init.js
CHANGED
|
@@ -145,39 +145,6 @@ try {
|
|
|
145
145
|
}
|
|
146
146
|
log("Project: " + (projectResult.registered ? "registered" : "failed"));
|
|
147
147
|
|
|
148
|
-
// ── Phase 3b: Set the dashboard's project as the default ─────────────
|
|
149
|
-
// The CLI takes the project implicitly from the dashboard's configured
|
|
150
|
-
// default (config.default_project). Idempotent: setdefaultproject upserts.
|
|
151
|
-
phase("default-project");
|
|
152
|
-
var defaultProjectResult;
|
|
153
|
-
try {
|
|
154
|
-
defaultProjectResult = await agent(
|
|
155
|
-
"Set the dashboard's default project to the dashboard itself.\\n\\n" +
|
|
156
|
-
"Dashboard slug: " + dashboardSlug + "\\n\\n" +
|
|
157
|
-
"Steps:\\n" +
|
|
158
|
-
"1. Call artifact_invoke_action with:\\n" +
|
|
159
|
-
" slug: '" + dashboardSlug + "'\\n" +
|
|
160
|
-
" action_name: 'setdefaultproject'\\n" +
|
|
161
|
-
" args: { project_id: '" + dashboardSlug + "' }\\n" +
|
|
162
|
-
"2. Return { defaulted: true } on success.\\n\\n" +
|
|
163
|
-
"Return JSON with defaulted (boolean).",
|
|
164
|
-
{
|
|
165
|
-
key: "project-2",
|
|
166
|
-
label: "Set default project",
|
|
167
|
-
schema: {
|
|
168
|
-
type: "object",
|
|
169
|
-
properties: {
|
|
170
|
-
defaulted: { type: "boolean" }
|
|
171
|
-
},
|
|
172
|
-
required: ["defaulted"]
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
);
|
|
176
|
-
} catch (e) {
|
|
177
|
-
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Default project could not be set", message: String(e.message || e) } };
|
|
178
|
-
}
|
|
179
|
-
log("Default project: " + (defaultProjectResult.defaulted ? "set" : "failed"));
|
|
180
|
-
|
|
181
148
|
// ── Phase 4: Polling cron ─────────────────────────────────────────────
|
|
182
149
|
// The cron body template lives in the repo at seed/cron-body-template.md.
|
|
183
150
|
// Placeholders: {crewHome}, {dashboardSlug} are replaced with actual values.
|
|
@@ -252,8 +219,10 @@ try {
|
|
|
252
219
|
"---\n" +
|
|
253
220
|
"## Muse Crew Orphan Sweep\n\n" +
|
|
254
221
|
"1. Call artifact_invoke_action on slug \"" + dashboardSlug + "\", action \"getdispatchstate\", args {}.\n" +
|
|
255
|
-
"2.
|
|
256
|
-
"
|
|
222
|
+
"2. From the response, extract the `id` field of each `ready_tasks` entry whose\n" +
|
|
223
|
+
" latest_session.status is \"running\", verbatim — copy the full ID exactly\n" +
|
|
224
|
+
" as it appears; do not shorten, truncate, or reformat it. Return them as a\n" +
|
|
225
|
+
" JSON array of strings. Do NOT reproduce any other part of the response.\n" +
|
|
257
226
|
"3. Run: CREW_ACTIVE_TASKS=\"<space-separated ids>\" " + crewHome + "/lib/orphan-sweep.sh clean\n" +
|
|
258
227
|
" If the list is empty, pass an empty string — do NOT omit the variable (the sweep fails closed without it).\n" +
|
|
259
228
|
"4. Report the output. If it says CLEAN, no action was needed.\n" +
|
package/workflows/docs.js
CHANGED
|
@@ -14,7 +14,6 @@ const inputs = args ?? {};
|
|
|
14
14
|
const taskId = inputs.task_id;
|
|
15
15
|
const taskTitle = inputs.task_title || "";
|
|
16
16
|
const taskDescription = inputs.task_description || "";
|
|
17
|
-
const firstSessionId = inputs.session_id || null;
|
|
18
17
|
const startStepIndex = inputs.start_step_index || 0;
|
|
19
18
|
|
|
20
19
|
// Config from args — backward-compatible fallbacks for manual launches
|
|
@@ -46,6 +45,33 @@ const REWORK_STEP = STEPS[WRITE_INDEX].name;
|
|
|
46
45
|
const MAX_REWORK = 2;
|
|
47
46
|
let reworkCount = 0;
|
|
48
47
|
let rejectionNotes = inputs.rejection_notes || "";
|
|
48
|
+
// Park the task for human attention and end the run. "blocked" is never
|
|
49
|
+
// manually authored — the dashboard derives it mechanically from unmet
|
|
50
|
+
// dependencies — so a workflow outcome that needs a human parks the task
|
|
51
|
+
// instead. Parking is one atomic dashboard action (parktask): the parked
|
|
52
|
+
// state and the explanatory note land in one transaction, never half.
|
|
53
|
+
// The dispatcher skips parked tasks; a human moving parked→todo
|
|
54
|
+
// mechanically resets the retry counters. Returns the workflow result
|
|
55
|
+
// envelope the launcher sees. If the park call itself fails, the run
|
|
56
|
+
// reports "failed" (retryable) so the next tick re-attempts the park —
|
|
57
|
+
// a lost park is never reported as parked.
|
|
58
|
+
async function parkTask(reason) {
|
|
59
|
+
log("Parking task " + taskId + " for human attention: " + reason);
|
|
60
|
+
var parkMessage = ("Parked: " + reason).slice(0, 1000);
|
|
61
|
+
try {
|
|
62
|
+
await agent(
|
|
63
|
+
"Park this task for human attention.\n" +
|
|
64
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"parktask\", args: " +
|
|
65
|
+
JSON.stringify({ task_id: taskId, message: parkMessage }) + ".\n" +
|
|
66
|
+
"The parked state is the human-attention signal — the dispatcher skips parked tasks.",
|
|
67
|
+
{ key: "park-task", label: "Parking task for human attention", schema: { type: "object" } }
|
|
68
|
+
);
|
|
69
|
+
} catch (parkErr) {
|
|
70
|
+
log("PARK FAILED for task " + taskId + ": " + (parkErr && parkErr.message ? parkErr.message : parkErr) + " — park did not land, reporting failed so the next tick retries");
|
|
71
|
+
return { status: "failed", task_id: taskId, reason: "park failed: " + reason, park_failed: true };
|
|
72
|
+
}
|
|
73
|
+
return { status: "parked", task_id: taskId, reason: reason };
|
|
74
|
+
}
|
|
49
75
|
let i = startStepIndex;
|
|
50
76
|
|
|
51
77
|
while (i < STEPS.length) {
|
|
@@ -77,11 +103,10 @@ while (i < STEPS.length) {
|
|
|
77
103
|
if (currentProject !== LAUNCH_PROJECT_ID) {
|
|
78
104
|
const abortMessage = "Task project changed mid-run from '" + LAUNCH_PROJECT_ID + "' to '" + currentProject + "' — aborting stale run. The dispatcher will re-launch from " + REWORK_STEP + " with the new project context.";
|
|
79
105
|
log(abortMessage);
|
|
80
|
-
const abortSessionPatch = (isFirstClaim && firstSessionId) ? "\"id\": \"" + firstSessionId + "\", " : "";
|
|
81
106
|
await agent(
|
|
82
107
|
"Abort the stale run.\n" +
|
|
83
108
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
84
|
-
"{ \"task_id\": \"" + taskId + "\",
|
|
109
|
+
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
|
|
85
110
|
"\"notes\": " + JSON.stringify(abortMessage) + " }.\n" +
|
|
86
111
|
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
87
112
|
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"identity\": \"" + step.identity + "\", \"message\": " + JSON.stringify(abortMessage) + " }.",
|
|
@@ -91,9 +116,35 @@ while (i < STEPS.length) {
|
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
118
|
|
|
119
|
+
// Self-claim — the dispatcher only recommends; the launched workflow claims
|
|
120
|
+
// the task as its first action, so a claim can never exist without a launched
|
|
121
|
+
// agent behind it. If another run claimed the task first (two poll ticks
|
|
122
|
+
// raced in the window before this run's claim), claimtask returns
|
|
123
|
+
// claimed:false and this run stands down as a duplicate.
|
|
94
124
|
let activeSessionId;
|
|
95
|
-
if (isFirstClaim
|
|
96
|
-
|
|
125
|
+
if (isFirstClaim) {
|
|
126
|
+
const claimResult = await agent(
|
|
127
|
+
"Claim this task for the " + step.name + " step.\n" +
|
|
128
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + taskId + "\", \"state\": \"in_progress\" }.\n" +
|
|
129
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
|
|
130
|
+
"{ \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"notes\": \"" + step.name + " step started\" }.\n" +
|
|
131
|
+
"Do not interpret the claim response. It already contains an explicit \"claimed\" field — copy it verbatim.\n" +
|
|
132
|
+
"Return { claimed: <verbatim>, session_id: \"<...>\" }. If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
|
|
133
|
+
{
|
|
134
|
+
key: "claim-" + step.name,
|
|
135
|
+
label: "Claiming " + step.name,
|
|
136
|
+
schema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: { claimed: { type: "boolean" }, session_id: { type: "string" } },
|
|
139
|
+
required: ["claimed", "session_id"]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
if (!claimResult.claimed) {
|
|
144
|
+
log("Standing down — task " + taskId + " was already claimed by another run");
|
|
145
|
+
return { status: "duplicate", task_id: taskId, reason: "task already claimed by another run" };
|
|
146
|
+
}
|
|
147
|
+
activeSessionId = claimResult.session_id;
|
|
97
148
|
} else {
|
|
98
149
|
const claimResult = await agent(
|
|
99
150
|
"Claim a session for this task step.\n" +
|
|
@@ -113,36 +164,305 @@ while (i < STEPS.length) {
|
|
|
113
164
|
activeSessionId = claimResult.session_id;
|
|
114
165
|
}
|
|
115
166
|
|
|
167
|
+
// Closeout is deterministic: the work agent returns the runtime's native
|
|
168
|
+
// transport envelope {"status": "ok", "result": "<prose report>"} with no
|
|
169
|
+
// schema, so the workflow receives the report as a plain string. There is
|
|
170
|
+
// no {"report"} wrapper: that invented shape invited agents to improvise
|
|
171
|
+
// sibling keys (notably "status"), which the runtime duck-types as its own
|
|
172
|
+
// envelope and fatally misparses. The envelope is the runtime's own
|
|
173
|
+
// documented shape — not a demand for machine-structured reasoning.
|
|
174
|
+
// The verdict is extracted mechanically by extractVerdict — never by an
|
|
175
|
+
// agent. The summary is the worker's report truncated. No formatter agent:
|
|
176
|
+
// it added a failure mode while contributing nothing the workflow doesn't
|
|
177
|
+
// compute itself.
|
|
178
|
+
const VERDICT_STEPS = ["Review"];
|
|
179
|
+
// The Review verdict is extracted DETERMINISTICALLY by workflow code —
|
|
180
|
+
// never by an agent. A missing, malformed, or contradictory VERDICT line
|
|
181
|
+
// first gets a bounded mechanical re-ask (reaskVerdict); only if that is
|
|
182
|
+
// exhausted does the phase fail closed (blocked): an ambiguous verdict
|
|
183
|
+
// must never pass.
|
|
184
|
+
function extractVerdict(workerText) {
|
|
185
|
+
// The verdict is the LAST VERDICT: PASS/FAIL in the report (contract: end
|
|
186
|
+
// your report with the verdict). This ignores literal VERDICT strings echoed
|
|
187
|
+
// from the worker's instructions (which contain quoted examples). Fail
|
|
188
|
+
// closed if: no verdict found, the last verdict is not in the trailing 100
|
|
189
|
+
// chars (verdict must be at the end), or conflicting verdicts appear in the
|
|
190
|
+
// trailing 200 chars. Word boundary prevents "PASSING" matching as PASS.
|
|
191
|
+
var text = workerText || "";
|
|
192
|
+
var regex = /VERDICT:\s*(PASS|FAIL)\b/gi;
|
|
193
|
+
var matches = [];
|
|
194
|
+
var m;
|
|
195
|
+
while ((m = regex.exec(text)) !== null) {
|
|
196
|
+
matches.push({ value: /FAIL/i.test(m[0]) ? "FAIL" : "PASS", index: m.index });
|
|
197
|
+
}
|
|
198
|
+
if (matches.length === 0) return { ok: false, count: 0 };
|
|
199
|
+
var last = matches[matches.length - 1];
|
|
200
|
+
if (last.index < text.length - 100) return { ok: false, count: matches.length };
|
|
201
|
+
var trailing = matches.filter(function (x) { return x.index >= text.length - 200; });
|
|
202
|
+
var uniq = trailing.map(function (x) { return x.value; }).filter(function (v, i, a) { return a.indexOf(v) === i; });
|
|
203
|
+
if (uniq.length !== 1) return { ok: false, count: matches.length };
|
|
204
|
+
return { ok: true, passed: last.value === "PASS" };
|
|
205
|
+
}
|
|
206
|
+
// Verdict re-ask (bug cd18ccc2): a verdict-step report that fails
|
|
207
|
+
// extractVerdict is not failed immediately. Stochastic verdict-line
|
|
208
|
+
// non-compliance (the agent did the work but omitted or garbled the VERDICT
|
|
209
|
+
// line) gets up to two bounded follow-up agent() calls whose only job is to
|
|
210
|
+
// read the preserved report and emit exactly one VERDICT line. The verdict
|
|
211
|
+
// is still extracted mechanically by extractVerdict — the re-ask agent
|
|
212
|
+
// transcribes, never decides the phase outcome. Each attempt uses a fresh
|
|
213
|
+
// stable-key suffix so a cached failure can never replay deterministically.
|
|
214
|
+
// Exhaustion keeps the existing fail-closed behavior. This is structure, not
|
|
215
|
+
// prompt hardening: no instruction text was stern-ified to get here.
|
|
216
|
+
function verdictReaskKey(stepName, reworkSuffix, attempt) {
|
|
217
|
+
return "verdict-reask-" + stepName + reworkSuffix + "-a" + attempt;
|
|
218
|
+
}
|
|
219
|
+
function buildVerdictReaskPrompt(stepName, workerText) {
|
|
220
|
+
return "Mechanical transcription task. Read the work report below and emit its verdict.\n\n" +
|
|
221
|
+
"WORK REPORT (verbatim):\n" + workerText + "\n\n" +
|
|
222
|
+
"Decide from the report's own content whether the " + stepName + " step clearly describes successful completion: " +
|
|
223
|
+
"if it does, the verdict is PASS; otherwise — failure, error, unfinished work, or unclear — the verdict is FAIL. " +
|
|
224
|
+
"Do NOT copy any VERDICT line from the report — decide from the content.\n\n" +
|
|
225
|
+
"Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your verdict line here\"}. " +
|
|
226
|
+
"The result must be exactly one line and nothing else: VERDICT: PASS or VERDICT: FAIL.";
|
|
227
|
+
}
|
|
228
|
+
async function reaskVerdict(stepName, reworkSuffix, workerText) {
|
|
229
|
+
var verdict = { ok: false, count: 0 };
|
|
230
|
+
for (var attempt = 1; attempt <= 2; attempt++) {
|
|
231
|
+
var reaskResult = null;
|
|
232
|
+
try {
|
|
233
|
+
reaskResult = await agent(buildVerdictReaskPrompt(stepName, workerText), {
|
|
234
|
+
key: verdictReaskKey(stepName, reworkSuffix, attempt),
|
|
235
|
+
label: "Verdict re-ask: " + stepName + " (attempt " + attempt + " of 2)",
|
|
236
|
+
timeoutMs: 180000
|
|
237
|
+
});
|
|
238
|
+
} catch (e) {
|
|
239
|
+
log(stepName + " verdict re-ask attempt " + attempt + " errored: " + ((e && e.message ? e.message : String(e)) || "").slice(0, 200));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
var reaskText = (typeof reaskResult === "string") ? reaskResult : "";
|
|
243
|
+
verdict = extractVerdict(reaskText);
|
|
244
|
+
if (verdict.ok) {
|
|
245
|
+
log(stepName + " verdict re-ask attempt " + attempt + " recovered verdict: " + (verdict.passed ? "PASS" : "FAIL"));
|
|
246
|
+
return verdict;
|
|
247
|
+
}
|
|
248
|
+
log(stepName + " verdict re-ask attempt " + attempt + " produced no readable verdict (" + verdict.count + " trailing-window matches)");
|
|
249
|
+
}
|
|
250
|
+
return verdict;
|
|
251
|
+
}
|
|
252
|
+
// Transport retry: the work-agent agent() call can throw even when the agent
|
|
253
|
+
// did the work. Stochastic envelope non-compliance (bare prose instead of
|
|
254
|
+
// the native {"status":"ok","result":"..."} envelope) trips the runtime's
|
|
255
|
+
// JSON-candidate heuristic when the prose contains a {...}-looking
|
|
256
|
+
// substring — canary 39457ee9's QA report quoted the change's own
|
|
257
|
+
// {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
|
|
258
|
+
// and the workflow discarded a complete VERDICT: PASS report as "no output".
|
|
259
|
+
// The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
|
|
260
|
+
// this covers the report never arriving. The assignment is retried boundedly
|
|
261
|
+
// with fresh keys (never a cached replay) before failing closed. Re-entry is
|
|
262
|
+
// safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
|
|
263
|
+
// retry trailer tells the agent to check existing state first and report
|
|
264
|
+
// rather than duplicate completed side effects, and the rework path already
|
|
265
|
+
// re-runs Build after rejection — Build re-entry is an established pattern.
|
|
266
|
+
function workRetryKey(stepName, reworkSuffix, attempt) {
|
|
267
|
+
return "work-" + stepName + reworkSuffix + "-t" + attempt;
|
|
268
|
+
}
|
|
269
|
+
function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
|
|
270
|
+
// reason: "discarded" (the runtime threw the output away — it could not be
|
|
271
|
+
// machine-read) or "empty" (agent() returned without throwing but produced
|
|
272
|
+
// nothing usable). The trailer tells the retry what to expect, not just to
|
|
273
|
+
// try again.
|
|
274
|
+
var why = reason === "empty"
|
|
275
|
+
? "your previous attempt returned no usable output"
|
|
276
|
+
: "your previous attempt's output could not be machine-read as JSON and was discarded";
|
|
277
|
+
return "\n\nTRANSPORT RETRY (attempt " + attempt + " of 2): " + why + ". " +
|
|
278
|
+
"First check existing state (worktree/branch at " + repoPath + "/.worktrees/" + taskId + ", the task branch, dashboard sessions for this task) — " +
|
|
279
|
+
"if the " + stepName + " work is already complete, report on what was done rather than duplicating side effects. " +
|
|
280
|
+
"Then return your report as JSON in exactly the shape specified above.";
|
|
281
|
+
}
|
|
282
|
+
function describeWorkAgentFailure(stepName, identity, attempts) {
|
|
283
|
+
// Honest classification of a work-agent call that yielded no usable
|
|
284
|
+
// report, with the per-attempt evidence preserved in the session notes.
|
|
285
|
+
// Two distinct cases:
|
|
286
|
+
// - agent() THREW: the runtime discarded output it could not machine-read
|
|
287
|
+
// (e.g. prose tripping the JSON-candidate heuristic). The raw output is
|
|
288
|
+
// gone — the workflow never received it — so the surviving error text is
|
|
289
|
+
// recorded here instead. (An earlier comment claimed the raw output was
|
|
290
|
+
// "preserved in the run record"; that was false — nothing preserved it —
|
|
291
|
+
// and the claim is removed.)
|
|
292
|
+
// - agent() RETURNED EMPTY without throwing: the child produced nothing
|
|
293
|
+
// usable. Each attempt's outcome is the evidence.
|
|
294
|
+
// attempts: [{threw, error, outcome}, ...], in order.
|
|
295
|
+
var parts = [];
|
|
296
|
+
for (var i = 0; i < attempts.length; i++) {
|
|
297
|
+
var a = attempts[i];
|
|
298
|
+
parts.push("attempt " + (i + 1) + "/" + attempts.length + ": " +
|
|
299
|
+
(a.threw ? "threw '" + a.error + "'" : "returned " + a.outcome));
|
|
300
|
+
}
|
|
301
|
+
var detail = parts.join("; ").replace(/"/g, "'").slice(0, 400);
|
|
302
|
+
var anyThrow = false;
|
|
303
|
+
for (var j = 0; j < attempts.length; j++) {
|
|
304
|
+
if (attempts[j].threw) { anyThrow = true; break; }
|
|
305
|
+
}
|
|
306
|
+
if (anyThrow) {
|
|
307
|
+
return {
|
|
308
|
+
notes: "Work agent produced no machine-readable report after " + attempts.length + " attempts; runtime discarded the output (" + detail + ")",
|
|
309
|
+
eventMessage: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
310
|
+
blockedReason: stepName + " work agent produced no machine-readable report after " + attempts.length + " attempts",
|
|
311
|
+
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
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
notes: "Work agent returned no usable output after " + attempts.length + " attempts (" + detail + ")",
|
|
316
|
+
eventMessage: stepName + " work agent returned no usable output after " + attempts.length + " attempts — phase failed, dispatcher will retry",
|
|
317
|
+
blockedReason: stepName + " work agent returned no usable output",
|
|
318
|
+
message: "The " + identity + " agent returned no usable output after " + attempts.length + " attempts (" + detail + ")."
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Marker line preservation: machine-readable VERDICT: lines are extracted
|
|
323
|
+
// from the full worker report and appended after the summary slice.
|
|
324
|
+
function extractMarkerLines(workerText) {
|
|
325
|
+
var lines = (workerText || "").split("\n");
|
|
326
|
+
var markers = [];
|
|
327
|
+
for (var i = 0; i < lines.length; i++) {
|
|
328
|
+
var line = lines[i].trim();
|
|
329
|
+
if (/^VERDICT:/i.test(line)) {
|
|
330
|
+
markers.push(line);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return markers.join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
116
336
|
var instructions = "";
|
|
117
337
|
if (step.name === "Triage") {
|
|
118
|
-
instructions = "Validate the task, check clarity, confirm the docs workflow assignment.\
|
|
338
|
+
instructions = "Validate the task, check clarity, confirm the docs workflow assignment.\nReport back in plain prose — your assessment.";
|
|
119
339
|
} else if (step.name === "Write") {
|
|
120
340
|
instructions = "Write or revise the documentation the task asks for.\nFollow Tate's voice — clear, conversational, no jargon unless it earns its place.\nDo your work in the project repository at " + REPO_PATH + " — all doc files go there, not under the crew home." +
|
|
121
341
|
(rejectionNotes ? "\n\nREWORK after review rejection. Address:\n" + rejectionNotes : "") +
|
|
122
|
-
"\
|
|
342
|
+
"\nReport back in plain prose — what you wrote and where.";
|
|
123
343
|
} else if (step.name === "Review") {
|
|
124
|
-
instructions = "Review the docs independently and cold — clarity, accuracy, completeness.\nNo prior context from the writer.\
|
|
344
|
+
instructions = "Review the docs independently and cold — clarity, accuracy, completeness.\nNo prior context from the writer.\nWrite your review as plain prose — findings, then decision. End your report with exactly one line: VERDICT: PASS if it passes, VERDICT: FAIL if it fails.";
|
|
125
345
|
}
|
|
126
346
|
|
|
127
|
-
|
|
347
|
+
// Work agent returns the runtime's native envelope {"status": "ok",
|
|
348
|
+
// "result": "<prose>"} with no schema. The runtime requires JSON output;
|
|
349
|
+
// the envelope is its own documented shape, so there is nothing for the
|
|
350
|
+
// agent to improvise. The workflow receives the prose report as a plain
|
|
351
|
+
// string. The verdict is still extracted deterministically from the report
|
|
352
|
+
// text by extractVerdict below — never by an agent.
|
|
353
|
+
var workPromptBase =
|
|
128
354
|
"Read the identity file at " + ORCH_PATH + "/identities/" + step.identity + ".md using the read tool, and embody that character fully.\n\n" +
|
|
129
355
|
"## Your Assignment\n\n" +
|
|
130
356
|
"Task: " + taskTitle + "\nTask ID: " + taskId + "\nDescription: " + taskDescription + "\nStep: " + step.name + "\nDashboard slug: " + DASHBOARD_SLUG + "\n\n" +
|
|
131
|
-
"## Instructions\n\n" + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. All file work under " + REPO_PATH + "
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
357
|
+
"## Instructions\n\n" + instructions + "\n\nCONSTRAINT: Do NOT call logevent or upsertagentsession — the workflow handles all phase tracking after your step completes.\n\nStay in character. All file work under " + REPO_PATH + "/.\n\n" +
|
|
358
|
+
"Return your work as JSON in exactly this shape: {\"status\": \"ok\", \"result\": \"your report here\"}. " +
|
|
359
|
+
"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.";
|
|
360
|
+
var workKeyBase = "work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : "");
|
|
361
|
+
var workerResult = null;
|
|
362
|
+
var workAttempts = [];
|
|
363
|
+
for (var workAttempt = 0; workAttempt <= 2; workAttempt++) {
|
|
364
|
+
var workKey = workAttempt === 0 ? workKeyBase : workRetryKey(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workAttempt);
|
|
365
|
+
var retryReason = workAttempt === 0 ? null : (workAttempts[workAttempt - 1].threw ? "discarded" : "empty");
|
|
366
|
+
try {
|
|
367
|
+
workerResult = await agent(
|
|
368
|
+
workPromptBase + (workAttempt === 0 ? "" : buildTransportRetryTrailer(step.name, REPO_PATH, taskId, workAttempt, retryReason)),
|
|
369
|
+
{
|
|
370
|
+
key: workKey,
|
|
371
|
+
label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"" + (workAttempt === 0 ? "" : " (transport retry " + workAttempt + " of 2)"),
|
|
372
|
+
timeoutMs: 3600000
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
// Success path: a non-blank string report is usable — keep it and stop
|
|
376
|
+
// retrying. Without this branch every attempt is discarded and the phase
|
|
377
|
+
// always fails (regression shipped in ecef136 when Date.now() was
|
|
378
|
+
// removed from this loop).
|
|
379
|
+
if (typeof workerResult === "string" && workerResult.trim()) {
|
|
380
|
+
if (workAttempt > 0) log(step.name + " work agent transport retry " + workAttempt + " returned a machine-readable report");
|
|
381
|
+
break;
|
|
140
382
|
}
|
|
383
|
+
var emptyOutcome = workerResult === null ? "null" : (typeof workerResult === "string" ? "blank string" : typeof workerResult);
|
|
384
|
+
workAttempts.push({ threw: false, error: "", outcome: emptyOutcome });
|
|
385
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 returned no usable output (" + emptyOutcome + ") — retrying with a fresh key");
|
|
386
|
+
workerResult = null;
|
|
387
|
+
} catch (e) {
|
|
388
|
+
var attemptErr = (e && e.message ? e.message : String(e)).replace(/"/g, "'").slice(0, 160);
|
|
389
|
+
workAttempts.push({ threw: true, error: attemptErr, outcome: "" });
|
|
390
|
+
log(step.name + " work agent attempt " + (workAttempt + 1) + " of 3 threw: " + attemptErr);
|
|
391
|
+
workerResult = null;
|
|
141
392
|
}
|
|
142
|
-
|
|
393
|
+
}
|
|
394
|
+
// The report arrives as a plain string — the runtime unwraps the envelope.
|
|
395
|
+
var workerText = (typeof workerResult === "string") ? workerResult : null;
|
|
396
|
+
|
|
397
|
+
if (typeof workerText !== "string" || !workerText.trim()) {
|
|
398
|
+
var workFailure = describeWorkAgentFailure(step.name, step.identity, workAttempts);
|
|
399
|
+
log(step.name + " " + workFailure.notes + " — marking failed for retry");
|
|
400
|
+
await agent(
|
|
401
|
+
"Record work failure.\n" +
|
|
402
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
403
|
+
"{ \"id\": \"" + activeSessionId + "\", \"task_id\": \"" + taskId + "\", \"identity\": \"" + step.identity + "\", \"step\": \"" + step.name + "\", \"status\": \"failed\", \"notes\": \"" + workFailure.notes + "\" }.\n" +
|
|
404
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
405
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + workFailure.eventMessage + "\" }.",
|
|
406
|
+
{ key: "record-block-" + step.name, label: "Recording work failure", schema: { type: "object" } }
|
|
407
|
+
);
|
|
408
|
+
return {
|
|
409
|
+
__hatchWorkflowControl: "blocked",
|
|
410
|
+
result: {
|
|
411
|
+
blocked_reason: workFailure.blockedReason,
|
|
412
|
+
message: workFailure.message,
|
|
413
|
+
task_id: taskId
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Verdict derivation (deterministic): for Review the workflow owns the
|
|
418
|
+
// verdict — extracted by regex from the trailing window from the worker's own report.
|
|
419
|
+
// A missing, malformed, or contradictory VERDICT line fails the phase (never silently passes).
|
|
420
|
+
// The session is marked "failed", NOT "blocked": the dispatcher retries
|
|
421
|
+
// failed sessions at the same step, while a "blocked" session is never
|
|
422
|
+
// picked up again (blocked is reserved for unmet dependencies).
|
|
423
|
+
var verdictPassed = null;
|
|
424
|
+
if (VERDICT_STEPS.indexOf(step.name) >= 0) {
|
|
425
|
+
var verdict = extractVerdict(workerText);
|
|
426
|
+
if (!verdict.ok) {
|
|
427
|
+
// Bounded mechanical re-ask before failing the phase: the report may
|
|
428
|
+
// be valid with a stochastically omitted or garbled verdict line.
|
|
429
|
+
log(step.name + " verdict line missing or ambiguous (" + verdict.count + " trailing-window matches) — attempting bounded re-ask");
|
|
430
|
+
verdict = await reaskVerdict(step.name, (reworkCount > 0 ? "-r" + reworkCount : ""), workerText);
|
|
431
|
+
}
|
|
432
|
+
if (!verdict.ok) {
|
|
433
|
+
log(step.name + " verdict re-ask exhausted — marking failed for retry");
|
|
434
|
+
await agent(
|
|
435
|
+
"Record verdict failure.\n" +
|
|
436
|
+
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
437
|
+
"{ \"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)\" }.\n" +
|
|
438
|
+
"Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"logevent\", args:\n" +
|
|
439
|
+
"{ \"task_id\": \"" + taskId + "\", \"type\": \"failed\", \"message\": \"" + step.name + " verdict line missing or ambiguous, re-ask exhausted — phase failed, dispatcher will retry\" }.",
|
|
440
|
+
{ key: "record-block-" + step.name, label: "Recording verdict failure", schema: { type: "object" } }
|
|
441
|
+
);
|
|
442
|
+
return {
|
|
443
|
+
__hatchWorkflowControl: "blocked",
|
|
444
|
+
result: {
|
|
445
|
+
blocked_reason: step.name + " worker report had no single unambiguous VERDICT: PASS/FAIL line (bounded re-ask exhausted)",
|
|
446
|
+
message: "The " + step.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.",
|
|
447
|
+
task_id: taskId
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
verdictPassed = verdict.passed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Deterministic closeout: no formatter agent. The verdict is mechanical
|
|
455
|
+
// (extractVerdict above); the summary is the worker's report truncated.
|
|
456
|
+
// For verdict steps passed comes from the verdict; for non-verdict steps
|
|
457
|
+
// the worker producing output means the step passed.
|
|
458
|
+
var stepResult = {
|
|
459
|
+
passed: verdictPassed !== null ? verdictPassed : true,
|
|
460
|
+
summary: workerText
|
|
461
|
+
};
|
|
143
462
|
|
|
144
|
-
|
|
145
|
-
const
|
|
463
|
+
var workerMarkers = extractMarkerLines(workerText);
|
|
464
|
+
const summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");
|
|
465
|
+
const passed = stepResult.passed === true;
|
|
146
466
|
const status = passed ? "completed" : "rejected";
|
|
147
467
|
|
|
148
468
|
await agent(
|
|
@@ -163,7 +483,7 @@ while (i < STEPS.length) {
|
|
|
163
483
|
reworkCount++;
|
|
164
484
|
if (reworkCount > MAX_REWORK) {
|
|
165
485
|
log("Max rework attempts reached for task " + taskId);
|
|
166
|
-
return
|
|
486
|
+
return await parkTask("Exceeded " + MAX_REWORK + " rework attempts after Review rejection");
|
|
167
487
|
}
|
|
168
488
|
rejectionNotes = summary;
|
|
169
489
|
i = WRITE_INDEX;
|