muse-crew 0.4.3 → 0.4.5

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.
@@ -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. Collect the task IDs of every entry in ready_tasks whose latest_session.status is \"running\".\n" +
256
- " (Sessions older than 1h are already reclassified as timed_out by the dashboard no extra math.)\n" +
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 + "\", " + abortSessionPatch + "\"identity\": \"" + step.identity + "\", \"step\": \"" + REWORK_STEP + "\", \"status\": \"failed\", " +
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 && firstSessionId) {
96
- activeSessionId = firstSessionId;
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.\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"your assessment\", \"passed\": true }. No prose, no markdown, just the JSON object.";
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
- "\nYour final response MUST be valid JSON and nothing else: { \"summary\": \"what you wrote and where\", \"passed\": true }. No prose, no markdown, just the JSON object.";
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.\nIf it passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\nIf it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\nNo prose, no markdown, just the JSON object.";
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
- const stepResult = await agent(
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
- key: "work-" + step.name + (reworkCount > 0 ? "-r" + reworkCount : ""),
134
- label: step.identity + ": " + step.name + " on \"" + taskTitle + "\"",
135
- timeoutMs: 3600000,
136
- schema: {
137
- type: "object",
138
- properties: { passed: { type: "boolean" }, summary: { type: "string" } },
139
- required: ["summary"]
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
- const summary = (stepResult.summary || "Step completed").slice(0, 2000);
145
- const passed = stepResult.passed !== false;
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 { status: "blocked", task_id: taskId, reason: "Exceeded " + MAX_REWORK + " rework attempts after Review rejection" };
486
+ return await parkTask("Exceeded " + MAX_REWORK + " rework attempts after Review rejection");
167
487
  }
168
488
  rejectionNotes = summary;
169
489
  i = WRITE_INDEX;