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.
@@ -1,6 +1,6 @@
1
1
  export const meta = {
2
2
  name: "crew-dispatch",
3
- description: "Thin Muse Crew dispatcher — reads the board, claims eligible tasks, launches the right workflow script per task.",
3
+ description: "Thin Muse Crew dispatcher — reads the board, recommends eligible tasks, returns structured launch records for the caller to launch (workflows can't launch workflows).",
4
4
  phases: ["dispatch"]
5
5
  };
6
6
 
@@ -16,34 +16,49 @@ const WORKFLOW_DIR = crewHome + "/workflows";
16
16
  // Project config is built from dashboard project records (getdispatchstate)
17
17
  // — no separate registry file needed.
18
18
 
19
- // Load canonical step definitions from workflow files (single source of truth)
20
- const registryResult = await agent(
21
- "Read the following 4 workflow files using the read tool and extract the `steps` array and `reworkTarget` string from each file's `export const meta` block at the top of the file.\n\n" +
22
- "Files:\n" +
23
- "1. " + WORKFLOW_DIR + "/standard.js\n" +
24
- "2. " + WORKFLOW_DIR + "/bugfix.js\n" +
25
- "3. " + WORKFLOW_DIR + "/chore.js\n" +
26
- "4. " + WORKFLOW_DIR + "/docs.js\n\n" +
27
- "Return an object with keys: standard, bugfix, chore, docs. Each value has { steps: [{name, identity}], reworkTarget: string }.",
28
- {
29
- key: "load-registry",
30
- label: "Loading workflow step registry",
31
- schema: {
32
- type: "object",
33
- properties: {
34
- standard: { type: "object" },
35
- bugfix: { type: "object" },
36
- chore: { type: "object" },
37
- docs: { type: "object" }
38
- },
39
- required: ["standard", "bugfix", "chore", "docs"]
19
+ // Per-poll processing cap. The launching cron times out at 300s; each
20
+ // processed task costs roughly one agent() round trip, so cap the number of
21
+ // tasks processed per poll and defer the rest to the next tick. Wall-clock
22
+ // timing is unavailable in workflow scripts (Date.now() is forbidden for
23
+ // deterministic replay), so a task-count cap is the mechanical bound.
24
+ // Overridable via inputs for QA probing.
25
+ const MAX_TASKS_PER_POLL = inputs.maxTasksPerPoll || 6;
26
+
27
+ // Workflow step registry: static per release. The poll cron body reads
28
+ // <crewHome>/workflows/registry.json (generated at release time from the
29
+ // workflow files, which stay the single source of truth) and passes it as
30
+ // inputs.registry. Fall back to the agent() load only when the caller
31
+ // predates the registry — slow, but never broken.
32
+ var registryResult = inputs.registry;
33
+ if (!registryResult) {
34
+ log("WARNING: no registry in args — falling back to slow registry load");
35
+ registryResult = await agent(
36
+ "Read the following 4 workflow files using the read tool and extract the `steps` array and `reworkTarget` string from each file's `export const meta` block at the top of the file.\n\n" +
37
+ "Files:\n" +
38
+ "1. " + WORKFLOW_DIR + "/standard.js\n" +
39
+ "2. " + WORKFLOW_DIR + "/bugfix.js\n" +
40
+ "3. " + WORKFLOW_DIR + "/chore.js\n" +
41
+ "4. " + WORKFLOW_DIR + "/docs.js\n\n" +
42
+ "Return an object with keys: standard, bugfix, chore, docs. Each value has { steps: [{name, identity}], reworkTarget: string }.",
43
+ {
44
+ key: "load-registry",
45
+ label: "Loading workflow step registry",
46
+ schema: {
47
+ type: "object",
48
+ properties: {
49
+ standard: { type: "object" },
50
+ bugfix: { type: "object" },
51
+ chore: { type: "object" },
52
+ docs: { type: "object" }
53
+ },
54
+ required: ["standard", "bugfix", "chore", "docs"]
55
+ }
40
56
  }
41
- }
42
- );
57
+ );
58
+ }
43
59
 
44
60
  // Derive lookup tables from canonical definitions
45
61
  const WORKFLOWS = {};
46
- const STEP_IDENTITY = {};
47
62
  const BUILD_STEPS = {};
48
63
  var wfNames = ["standard", "bugfix", "chore", "docs"];
49
64
  for (var wi = 0; wi < wfNames.length; wi++) {
@@ -51,9 +66,6 @@ for (var wi = 0; wi < wfNames.length; wi++) {
51
66
  var wfData = registryResult[wfName];
52
67
  WORKFLOWS[wfName] = wfData.steps.map(function(s) { return s.name; });
53
68
  BUILD_STEPS[wfName] = wfData.reworkTarget;
54
- for (var si = 0; si < wfData.steps.length; si++) {
55
- STEP_IDENTITY[wfData.steps[si].name] = wfData.steps[si].identity;
56
- }
57
69
  }
58
70
 
59
71
  phase("dispatch");
@@ -62,7 +74,12 @@ phase("dispatch");
62
74
  const boardResult = await agent(
63
75
  "Read the dispatch state.\n" +
64
76
  "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getdispatchstate\", args {}.\n" +
65
- "Return the full JSON response ready_tasks array, projects array, config object, counts object.",
77
+ "Return ONLY the following projection as JSON. Do not reproduce any other fields:\n" +
78
+ "{\n" +
79
+ " \"ready_tasks\": [ { \"id\", \"title\", \"description\", \"state\", \"project\", \"workflow\", \"blocked\", \"latest_session\": { \"id\", \"status\", \"step\", \"notes\" } or null } ],\n" +
80
+ " \"projects\": [ { \"id\", \"simultaneity\", \"quiesced\", \"repo_path\", \"deploy_type\", \"deploy_slug\", \"description\" } ],\n" +
81
+ " \"config\": { ... } // the config object as-is\n" +
82
+ "}",
66
83
  {
67
84
  key: "read-board",
68
85
  label: "Reading board state",
@@ -71,8 +88,7 @@ const boardResult = await agent(
71
88
  properties: {
72
89
  ready_tasks: { type: "array" },
73
90
  projects: { type: "array" },
74
- config: { type: "object" },
75
- counts: { type: "object" }
91
+ config: { type: "object" }
76
92
  },
77
93
  required: ["ready_tasks"]
78
94
  }
@@ -83,10 +99,8 @@ const allTasks = boardResult.ready_tasks || [];
83
99
  const config = boardResult.config || {};
84
100
  const projects = boardResult.projects || [];
85
101
 
86
- // Default project: explicit arg wins, then the dashboard's configured default
87
- // project (config.default_project set by crew-init or the dashboard UI),
88
- // then the first registered project as a last resort.
89
- const DEFAULT_PROJECT = inputs.defaultProject || config.default_project || (projects.length > 0 ? projects[0].id : "");
102
+ // Default project: explicit arg, or first registered project
103
+ const DEFAULT_PROJECT = inputs.defaultProject || (projects.length > 0 ? projects[0].id : "");
90
104
 
91
105
  // Per-project quiesce: build a set of quiesced project IDs
92
106
  const quiescedProjects = {};
@@ -120,8 +134,112 @@ for (var pi = 0; pi < projects.length; pi++) {
120
134
 
121
135
  log("Board: " + allTasks.length + " active tasks, quiesced=" + Object.keys(quiescedProjects).join(","));
122
136
 
137
+ // ── Idle playtesting helpers ─────────────────────────────────────────
138
+ // A playtest is an ordinary board task (filed_by "hazel") whose title starts
139
+ // with PLAYTEST_PREFIX. The filing carries the full assignment (journey +
140
+ // persona + filings cap), so a playtest always enters its workflow at QA.
141
+ const PLAYTEST_PREFIX = "Idle playtest: ";
142
+
143
+ function isPlaytestTask(t) {
144
+ return !!t && typeof t.title === "string" && t.title.indexOf(PLAYTEST_PREFIX) === 0;
145
+ }
146
+
147
+ function qaStepIndex(workflowName) {
148
+ var steps = WORKFLOWS[workflowName] || WORKFLOWS.standard;
149
+ var idx = steps.indexOf("QA");
150
+ return idx >= 0 ? idx : 0;
151
+ }
152
+
153
+ // Roster format: a markdown string; each "## <name>" section is one journey
154
+ // (heading = journey name, body = the steps to walk). Returns [{name, body}].
155
+ function parseJourneys(md) {
156
+ var journeys = [];
157
+ if (!md || !String(md).trim()) return journeys;
158
+ var lines = String(md).split("\n");
159
+ var cur = null;
160
+ for (var i = 0; i < lines.length; i++) {
161
+ var m = lines[i].match(/^##\s+(.+?)\s*$/);
162
+ if (m) {
163
+ if (cur) { cur.body = cur.body.trim(); journeys.push(cur); }
164
+ cur = { name: m[1], body: "" };
165
+ } else if (cur) {
166
+ cur.body += lines[i] + "\n";
167
+ }
168
+ }
169
+ if (cur) { cur.body = cur.body.trim(); journeys.push(cur); }
170
+ return journeys.filter(function (j) { return j.name.length > 0; });
171
+ }
172
+
173
+ function configInt(cfg, key, fallback) {
174
+ var n = parseInt(cfg[key], 10);
175
+ return isNaN(n) ? fallback : n;
176
+ }
177
+
178
+ // Pure: unit-testable. Decides park vs retry from the dashboard's explicit
179
+ // retry state (task.retry.consecutive_failures, computed deterministically
180
+ // server-side). Missing/invalid state fails open to "retry" — a dashboard
181
+ // that predates the retry field must not strand a task. A non-positive cap
182
+ // disables the cap entirely (unbounded retries).
183
+ function decideRetry(consecutiveFailures, maxFailures) {
184
+ if (!(maxFailures > 0)) return "retry";
185
+ if (typeof consecutiveFailures !== "number" || consecutiveFailures < 0) return "retry";
186
+ return consecutiveFailures >= maxFailures ? "park" : "retry";
187
+ }
188
+
189
+ function buildPlaytestDescription(journey, personaName, personaFile, maxFilings) {
190
+ var filingRule = maxFilings === 0
191
+ ? "File no follow-up tasks on this run (max_filings is 0). Note anything you find in your summary instead."
192
+ : "File follow-up tasks for real issues you find: at most " + maxFilings + " on this run (max_filings). If you find more, note the extras in your summary.";
193
+ var body = journey.body.length > 3000 ? journey.body.slice(0, 3000) + "\n[truncated]" : journey.body;
194
+ return (
195
+ "IDLE PLAYTEST — this task carries no code change. You are Hazel, code-blind QA.\n" +
196
+ "\n" +
197
+ "Assignment: journey \"" + journey.name + "\" as the \"" + personaName + "\" persona.\n" +
198
+ "Persona file: " + crewHome + "/.orchestration/personas/" + personaFile + " — read it first and wear that perspective for the whole run.\n" +
199
+ "\n" +
200
+ "Journey:\n" + body + "\n" +
201
+ "\n" +
202
+ "Rules:\n" +
203
+ "- Walk the journey end to end from a user's perspective, wearing the persona.\n" +
204
+ "- Use the normal QA surface for this project (visual inspection via artifact_inspect for artifact projects; read-only dashboard API checks otherwise).\n" +
205
+ "- There is no code change under test: skip change-verification steps (provenance / publish checks) and judge only what you observe.\n" +
206
+ "- " + filingRule + "\n" +
207
+ "- File via the existing createtask action with filed_by \"hazel\": set workflow to \"standard\" when the fix is clear feature work, or omit workflow (untriaged) when unsure — triage routes untriaged filings to bugfix/feature as usual.\n"
208
+ ).slice(0, 4800);
209
+ }
210
+
123
211
  // ── 2. Determine eligible tasks ──────────────────────────────────────
124
212
  const eligible = [];
213
+ const retryCandidates = [];
214
+ const rejectionCandidates = [];
215
+
216
+ // ── Retry cap ────────────────────────────────────────────────────────
217
+ // Symphony owns phase-redispatch policy (coordination layer): the spec
218
+ // defines backoff but no attempt cap ("implementation-defined"), so the
219
+ // dispatcher defines it here. After MAX_CONSECUTIVE_FAILURES consecutive
220
+ // failed/timed_out sessions for the same step, the task is parked for
221
+ // human attention instead of retried forever. The count is explicit
222
+ // dashboard-owned state (task.retry.consecutive_failures from
223
+ // getdispatchstate) — the dispatcher never projects history and never
224
+ // parses event messages. Parked tasks leave getdispatchstate's ready set,
225
+ // so the park happens exactly once; parked → todo is the designed human
226
+ // decision point, and the dashboard stamps retry_reset_at on that
227
+ // transition so the streak restarts mechanically on re-queue.
228
+ // Overridable via dashboard config key "maxConsecutiveFailures";
229
+ // 0 or negative disables the cap (pre-cap behavior: unbounded retries).
230
+ var MAX_CONSECUTIVE_FAILURES = configInt(config, "maxConsecutiveFailures", 3);
231
+
232
+ // ── Rejection budget ─────────────────────────────────────────────────
233
+ // Review/QA rejections route through rework, but the workflow's in-run
234
+ // rework counter resets every tick — a rejecting Review plus a flaky Build
235
+ // can cycle forever across ticks, evading both the in-run budget and the
236
+ // operational cap above. The dashboard owns the cross-tick count
237
+ // (task.retry.rejections_since_reset: rejected sessions since the
238
+ // retry_reset_at watermark, any step). When it reaches
239
+ // MAX_CONSECUTIVE_REJECTIONS the dispatcher parks the task instead of
240
+ // redispatching to Build/Write. Same shape as the failure cap:
241
+ // overridable via "maxConsecutiveRejections"; 0 or negative disables.
242
+ var MAX_CONSECUTIVE_REJECTIONS = configInt(config, "maxConsecutiveRejections", 2);
125
243
 
126
244
  for (var t = 0; t < allTasks.length; t++) {
127
245
  var task = allTasks[t];
@@ -139,7 +257,10 @@ for (var t = 0; t < allTasks.length; t++) {
139
257
  var steps = WORKFLOWS[workflow] || WORKFLOWS.standard;
140
258
 
141
259
  if (task.state === "todo") {
142
- eligible.push({ task: task, startStep: 0, reason: "new", workflow: workflow });
260
+ // Idle playtests always enter at QA the filing carries the full
261
+ // assignment, so Triage/Map have nothing to add.
262
+ var todoStart = isPlaytestTask(task) ? qaStepIndex(task.workflow || "standard") : 0;
263
+ eligible.push({ task: task, startStep: todoStart, reason: "new", workflow: workflow });
143
264
  continue;
144
265
  }
145
266
 
@@ -155,6 +276,10 @@ for (var t = 0; t < allTasks.length; t++) {
155
276
  if (latest.status === "completed") {
156
277
  var stepName = latest.step || "";
157
278
  var stepIndex = steps.indexOf(stepName);
279
+ if (stepIndex < 0) {
280
+ log("Skipped \"" + task.title + "\" — step \"" + stepName + "\" not in " + workflow + " step registry");
281
+ continue;
282
+ }
158
283
  var nextIndex = stepIndex + 1;
159
284
 
160
285
  if (nextIndex >= steps.length) {
@@ -169,30 +294,233 @@ for (var t = 0; t < allTasks.length; t++) {
169
294
  var buildStepName = BUILD_STEPS[workflow] || "Build";
170
295
  var buildIdx = steps.indexOf(buildStepName);
171
296
  if (buildIdx >= 0) {
172
- eligible.push({ task: task, startStep: buildIdx, reason: "rework", workflow: workflow, rejectionNotes: latest.notes || "" });
297
+ // Budgeted in 2a before becoming eligible.
298
+ rejectionCandidates.push({ task: task, startStep: buildIdx, reason: "rework", workflow: workflow, rejectionNotes: latest.notes || "" });
173
299
  }
174
300
  continue;
175
301
  }
176
302
 
177
- if (latest.status === "failed" || latest.status === "timed_out") {
303
+ // "stalled" is operational like "timed_out": the run died without a verdict.
304
+ if (latest.status === "failed" || latest.status === "timed_out" || latest.status === "stalled") {
178
305
  var failedStep = latest.step || "";
179
306
  var retryIdx = steps.indexOf(failedStep);
180
- eligible.push({ task: task, startStep: retryIdx >= 0 ? retryIdx : 0, reason: "retry", workflow: workflow });
307
+ if (retryIdx < 0) {
308
+ log("Skipped \"" + task.title + "\" — step \"" + failedStep + "\" not in " + workflow + " step registry");
309
+ continue;
310
+ }
311
+ // Counted against the retry cap in 2a before becoming eligible.
312
+ retryCandidates.push({ task: task, startStep: retryIdx, reason: "retry", workflow: workflow, failedStep: failedStep });
181
313
  continue;
182
314
  }
183
315
  }
184
316
 
185
- log("Eligible: " + eligible.length + " tasks");
317
+ // ── 2a. Retry cap and rejection budget ─────────────────────────────
318
+ // A retry candidate becomes eligible only while its step has failed fewer
319
+ // than MAX_CONSECUTIVE_FAILURES times in a row. A rejection candidate
320
+ // becomes eligible only while its rejections-since-reset are fewer than
321
+ // MAX_CONSECUTIVE_REJECTIONS. Both counts are explicit dashboard-owned
322
+ // state (task.retry from getdispatchstate), computed deterministically
323
+ // server-side. The dispatcher consumes them directly: no LLM history
324
+ // projection, no event-message parsing, no "[retry-cap]" markers.
325
+ // A missing/invalid retry field fails open to "retry" (see decideRetry)
326
+ // so an older dashboard never strands a task — with a warning.
327
+ var parkJobs = [];
328
+ if (retryCandidates.length > 0) {
329
+ for (var rci = 0; rci < retryCandidates.length; rci++) {
330
+ var rc = retryCandidates[rci];
331
+ var retryState = (rc.task && rc.task.retry) || null;
332
+ var consecutiveFailures = retryState ? retryState.consecutive_failures : undefined;
333
+ if (typeof consecutiveFailures !== "number" || consecutiveFailures < 0) {
334
+ log("WARNING: invalid retry state for \"" + rc.task.title + "\" (consecutive_failures=" + String(consecutiveFailures) + ") — retrying, cap unenforced");
335
+ } else if (retryState == null) {
336
+ log("WARNING: no explicit retry state for \"" + rc.task.title + "\" — retrying (dashboard predates retry field)");
337
+ }
338
+ if (decideRetry(consecutiveFailures, MAX_CONSECUTIVE_FAILURES) === "park") {
339
+ parkJobs.push({ task: rc.task, message: ("Parked: " + rc.failedStep + " failed " + consecutiveFailures + " consecutive times (cap " + MAX_CONSECUTIVE_FAILURES + ")").slice(0, 1000) });
340
+ } else {
341
+ eligible.push({ task: rc.task, startStep: rc.startStep, reason: "retry", workflow: rc.workflow });
342
+ }
343
+ }
344
+ }
345
+ if (rejectionCandidates.length > 0) {
346
+ for (var jci = 0; jci < rejectionCandidates.length; jci++) {
347
+ var jc = rejectionCandidates[jci];
348
+ var jcRetryState = (jc.task && jc.task.retry) || null;
349
+ var rejectionsSinceReset = jcRetryState ? jcRetryState.rejections_since_reset : undefined;
350
+ if (typeof rejectionsSinceReset !== "number" || rejectionsSinceReset < 0) {
351
+ log("WARNING: invalid retry state for \"" + jc.task.title + "\" (rejections_since_reset=" + String(rejectionsSinceReset) + ") — reworking, budget unenforced");
352
+ } else if (jcRetryState == null) {
353
+ log("WARNING: no explicit retry state for \"" + jc.task.title + "\" — reworking (dashboard predates retry field)");
354
+ }
355
+ if (decideRetry(rejectionsSinceReset, MAX_CONSECUTIVE_REJECTIONS) === "park") {
356
+ parkJobs.push({ task: jc.task, message: ("Parked: " + rejectionsSinceReset + " rejections since reset (budget " + MAX_CONSECUTIVE_REJECTIONS + ") — needs human review").slice(0, 1000) });
357
+ } else {
358
+ eligible.push({ task: jc.task, startStep: jc.startStep, reason: "rework", workflow: jc.workflow, rejectionNotes: jc.rejectionNotes });
359
+ }
360
+ }
361
+ }
362
+
363
+ // Batch all parks into one agent call (one round trip) via the atomic
364
+ // parktask action: state=parked plus the note land in one transaction,
365
+ // so a park can never half-apply. A park failure must not kill the tick —
366
+ // the task stays retryable and is re-parked next tick, so this is
367
+ // fail-safe. The dashboard stamps retry_reset_at on parked→todo, which
368
+ // restarts both counters mechanically.
369
+ if (parkJobs.length > 0) {
370
+ var parkSteps = [];
371
+ for (var pji = 0; pji < parkJobs.length; pji++) {
372
+ var pj = parkJobs[pji];
373
+ log("Parking \"" + pj.task.title + "\" — " + pj.message);
374
+ parkSteps.push(
375
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"parktask\", args: { \"task_id\": \"" + pj.task.id + "\", \"message\": " + JSON.stringify(pj.message) + " }."
376
+ );
377
+ }
378
+ try {
379
+ await agent(
380
+ "Park " + parkJobs.length + " task(s) for human attention, in order:\n\n" + parkSteps.join("\n\n"),
381
+ { key: "park-batch", label: "Parking " + parkJobs.length + " task(s) at retry cap", schema: { type: "object" } }
382
+ );
383
+ log("Parked " + parkJobs.length + " task(s)");
384
+ } catch (parkErr) {
385
+ log("WARNING: park batch failed (" + (parkErr.message || String(parkErr)).slice(0, 200) + ") — tasks remain retryable");
386
+ }
387
+ } else {
388
+ // Cap disabled, or no retry candidates: pre-cap behavior.
389
+ for (var dr = 0; dr < retryCandidates.length; dr++) {
390
+ var drc = retryCandidates[dr];
391
+ eligible.push({ task: drc.task, startStep: drc.startStep, reason: "retry", workflow: drc.workflow });
392
+ }
393
+ }
394
+
395
+ log("Eligible: " + eligible.length + " tasks (" + retryCandidates.length + " retry candidates)");
186
396
 
397
+ // ── 2b. Idle playtesting ────────────────────────────────────────────
398
+ // No claimable tasks: file a playtest instead of idling. Guards:
399
+ // (a) this block is reachable ONLY when the claimable set is empty — there
400
+ // is no minimum-task-threshold knob;
401
+ // (b) never more than one playtest outstanding (todo or in_progress);
402
+ // (c) real work always wins — any eligible task skips this block entirely.
403
+ // A quiesced default project is also disarmed: the kill switch pauses
404
+ // dispatch for that project, and a playtest is dispatch.
187
405
  if (eligible.length === 0) {
188
- await agent(
189
- "Acknowledge the poll.\nCall artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"acknowledge_poll\", args {}.",
190
- { key: "ack-empty", label: "Acknowledging poll (nothing to do)", schema: { type: "object" } }
191
- );
192
- return { status: "ok", message: "No tasks ready." };
406
+ var ptProject = DEFAULT_PROJECT;
407
+ var ptArmed = true;
408
+ var ptWhy = "";
409
+
410
+ if (!ptProject) { ptArmed = false; ptWhy = "no default project"; }
411
+ if (ptArmed && quiescedProjects[ptProject]) { ptArmed = false; ptWhy = "project " + ptProject + " is quiesced"; }
412
+
413
+ // Guard (b): a playtest is already outstanding
414
+ if (ptArmed) {
415
+ for (var gi = 0; gi < allTasks.length; gi++) {
416
+ var gt = allTasks[gi];
417
+ if (isPlaytestTask(gt) && (gt.state === "todo" || gt.state === "in_progress")) {
418
+ ptArmed = false; ptWhy = "playtest already outstanding: " + gt.id;
419
+ break;
420
+ }
421
+ }
422
+ }
423
+
424
+ // Journeys roster: playtest.<project>.journeys (markdown string).
425
+ // Missing, empty, or unparseable => trigger no-ops (unarmed), no error.
426
+ var ptJourneys = [];
427
+ if (ptArmed) {
428
+ ptJourneys = parseJourneys(config["playtest." + ptProject + ".journeys"]);
429
+ if (ptJourneys.length === 0) { ptArmed = false; ptWhy = "no journeys roster for project " + ptProject + " (unarmed)"; }
430
+ }
431
+
432
+ // Personas: the existing personas/*.md files, sorted by filename.
433
+ var ptPersonas = [];
434
+ if (ptArmed) {
435
+ var personaResult = await agent(
436
+ "List the QA persona files.\n" +
437
+ "Use the read tool to list the directory \"" + crewHome + "/.orchestration/personas\".\n" +
438
+ "Return the sorted basenames of all files ending in .md (e.g. \"beginner.md\").\n" +
439
+ "If the directory cannot be read, return an empty list.",
440
+ {
441
+ key: "list-personas",
442
+ label: "Listing QA personas",
443
+ schema: {
444
+ type: "object",
445
+ properties: { personas: { type: "array" } },
446
+ required: ["personas"]
447
+ }
448
+ }
449
+ );
450
+ ptPersonas = (personaResult.personas || []).filter(function (f) {
451
+ return typeof f === "string" && f.slice(-3).toLowerCase() === ".md";
452
+ }).sort();
453
+ if (ptPersonas.length === 0) { ptArmed = false; ptWhy = "no persona files in " + crewHome + "/.orchestration/personas"; }
454
+ }
455
+
456
+ if (ptArmed) {
457
+ var J = ptJourneys.length;
458
+ var P = ptPersonas.length;
459
+ // Mod the indexes so roster length changes can't break the rotation.
460
+ var jc = configInt(config, "playtest." + ptProject + ".journey_cursor", 0);
461
+ var pc = configInt(config, "playtest." + ptProject + ".persona_cursor", 0);
462
+ var pti = ((jc % J) + J) % J;
463
+ var ppi = ((pc % P) + P) % P;
464
+ var ptJourney = ptJourneys[pti];
465
+ var ptPersonaFile = ptPersonas[ppi];
466
+ var ptPersonaName = ptPersonaFile.replace(/\.md$/i, "");
467
+ // Advance: journey += 1 mod J; persona advances only when journey wraps,
468
+ // so the rotation covers the full journeys x personas matrix over time.
469
+ var nextJ = (pti + 1) % J;
470
+ var nextP = (nextJ === 0) ? (ppi + 1) % P : ppi;
471
+ var maxFilings = configInt(config, "playtest." + ptProject + ".max_filings", 5);
472
+ if (maxFilings < 0) maxFilings = 5;
473
+
474
+ var ptTitle = (PLAYTEST_PREFIX + ptJourney.name + " as " + ptPersonaName).slice(0, 160);
475
+ var ptDesc = buildPlaytestDescription(ptJourney, ptPersonaName, ptPersonaFile, maxFilings);
476
+ var safeTitle = ptTitle.replace(/"/g, "'");
477
+ var safeDesc = ptDesc.replace(/"/g, "'");
478
+
479
+ var fileResult = await agent(
480
+ "Advance the playtest cursors, then file the idle playtest task.\n" +
481
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updateconfig\", args: { \"key\": \"playtest." + ptProject + ".journey_cursor\", \"value\": \"" + nextJ + "\" }.\n" +
482
+ "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updateconfig\", args: { \"key\": \"playtest." + ptProject + ".persona_cursor\", \"value\": \"" + nextP + "\" }.\n" +
483
+ "Then call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"createtask\", args: { \"title\": \"" + safeTitle + "\", \"description\": \"SEE BELOW\", \"project\": \"" + ptProject + "\", \"workflow\": \"standard\", \"filed_by\": \"hazel\" }.\n" +
484
+ "For the description, use EXACTLY the following text (it is the playtest assignment — do not paraphrase):\n" +
485
+ "---DESCRIPTION START---\n" + safeDesc + "\n---DESCRIPTION END---\n" +
486
+ "Return { \"task_id\": \"<the created task's id>\" }. If createtask fails, return { \"task_id\": \"\" }.",
487
+ {
488
+ key: "file-playtest",
489
+ label: "Filing idle playtest",
490
+ schema: {
491
+ type: "object",
492
+ properties: { task_id: { type: "string" } },
493
+ required: ["task_id"]
494
+ }
495
+ }
496
+ );
497
+
498
+ if (fileResult.task_id) {
499
+ eligible.push({
500
+ task: { id: fileResult.task_id, title: ptTitle, description: ptDesc, project: ptProject, workflow: "standard", state: "todo" },
501
+ startStep: qaStepIndex("standard"),
502
+ reason: "playtest",
503
+ workflow: "standard"
504
+ });
505
+ log("Filed idle playtest \"" + ptTitle + "\" [" + ptProject + "] journey " + pti + "/" + J + ", persona " + ppi + "/" + P + ", max_filings " + maxFilings);
506
+ } else {
507
+ log("Idle playtest filing failed — cursors already advanced, assignment skipped");
508
+ }
509
+ } else {
510
+ log("Idle playtest not armed: " + ptWhy);
511
+ }
193
512
  }
194
513
 
195
514
  // ── 3. Process eligible tasks, respecting per-project simultaneity ───
515
+ // Per-poll cap: process at most MAX_TASKS_PER_POLL tasks per tick so the run
516
+ // stays comfortably under the 300s cron timeout (each recommended task
517
+ // becomes one launched workflow run). Deferred tasks stay eligible and are
518
+ // picked up on the next tick. A poll must still ack and exit cleanly — never
519
+ // die mid-run.
520
+ var results = [];
521
+ var partial = false;
522
+
523
+ {
196
524
  // Count in-flight tasks per project to enforce per-project limits
197
525
  var inFlightByProject = {};
198
526
  for (var ct = 0; ct < allTasks.length; ct++) {
@@ -216,7 +544,15 @@ for (var ei = 0; ei < eligible.length; ei++) {
216
544
  log("Skipped \"" + eitem.task.title + "\" — project " + ep + " at simultaneity limit (" + limit + ")");
217
545
  }
218
546
  }
219
- var results = [];
547
+
548
+ // Per-poll cap: defer tasks beyond MAX_TASKS_PER_POLL to the next tick so
549
+ // the run stays under the cron timeout. Deferred tasks remain eligible.
550
+ if (toProcess.length > MAX_TASKS_PER_POLL) {
551
+ var deferred = toProcess.length - MAX_TASKS_PER_POLL;
552
+ log("Deferring " + deferred + " task(s) to next poll (per-poll cap " + MAX_TASKS_PER_POLL + ")");
553
+ toProcess = toProcess.slice(0, MAX_TASKS_PER_POLL);
554
+ partial = true;
555
+ }
220
556
 
221
557
  for (var p = 0; p < toProcess.length; p++) {
222
558
  var item = toProcess[p];
@@ -236,46 +572,15 @@ for (var p = 0; p < toProcess.length; p++) {
236
572
  }
237
573
 
238
574
  var nextStepName = isteps[item.startStep];
239
- var identity = STEP_IDENTITY[nextStepName] || "sage";
240
-
241
- // Set to in_progress if todo
242
- if (itask.state === "todo") {
243
- await agent(
244
- "Set task to in_progress.\n" +
245
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"updatetask\", args: { \"id\": \"" + itask.id + "\", \"state\": \"in_progress\" }.",
246
- { key: "activate-" + itask.id, label: "Activating: " + itask.title, schema: { type: "object" } }
247
- );
248
- }
249
575
 
250
- // Atomic claim check response before proceeding
251
- var claimResult = await agent(
252
- "Claim the task for the " + nextStepName + " step.\n" +
253
- "Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"claimtask\", args:\n" +
254
- "{ \"task_id\": \"" + itask.id + "\", \"identity\": \"" + identity + "\", \"step\": \"" + nextStepName + "\", \"notes\": \"" + nextStepName + " step started\" }.\n" +
255
- "Do not interpret the response. It already contains an explicit \"claimed\" field copy it verbatim.\n" +
256
- "Return { claimed: <the response's claimed field exactly>, session_id: \"<the response's session_id field>\" }.\n" +
257
- "If claimed is false there is no session_id; return { claimed: false, session_id: \"\" }.",
258
- {
259
- key: "claim-" + itask.id,
260
- label: "Claiming " + nextStepName + " for: " + itask.title,
261
- schema: {
262
- type: "object",
263
- properties: {
264
- claimed: { type: "boolean" },
265
- session_id: { type: "string" }
266
- },
267
- required: ["claimed", "session_id"]
268
- }
269
- }
270
- );
271
-
272
- if (!claimResult.claimed) {
273
- log("Skipped \"" + itask.title + "\" — already claimed by another tick");
274
- results.push({ task_id: itask.id, action: "skipped" });
275
- continue;
276
- }
277
-
278
- // Return claim for the caller to launch (workflows can't launch workflows)
576
+ // Recommend — do NOT claim. The dispatcher never writes task state or
577
+ // sessions for recommended tasks: the launched workflow claims the task
578
+ // itself as its first action (activate + claim), so a claim can never
579
+ // exist without a launched agent behind it. Two poll ticks can still
580
+ // recommend the same task in the window before the first workflow
581
+ // self-claims; the loser's claimtask returns claimed:false and that run
582
+ // stands down as a duplicate. (Workflows can't launch workflows, so the
583
+ // caller launches each recommendation from a non-workflow context.)
279
584
  var scriptPath = WORKFLOW_DIR + "/" + iworkflow + ".js";
280
585
  var taskProject = itask.project || DEFAULT_PROJECT;
281
586
  var projectCfg = PROJECTS[taskProject] || PROJECTS[DEFAULT_PROJECT];
@@ -283,7 +588,6 @@ for (var p = 0; p < toProcess.length; p++) {
283
588
  task_id: itask.id,
284
589
  task_title: itask.title || "",
285
590
  task_description: itask.description || "",
286
- session_id: claimResult.session_id,
287
591
  start_step_index: item.startStep,
288
592
  rejection_notes: item.rejectionNotes || "",
289
593
  project_config: projectCfg,
@@ -292,28 +596,31 @@ for (var p = 0; p < toProcess.length; p++) {
292
596
  crewHome: crewHome
293
597
  };
294
598
 
295
- log("Claimed " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
296
- results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "claimed", scriptPath: scriptPath, args: launchArgs });
297
- }
599
+ log("Recommended " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
600
+ results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "recommended", scriptPath: scriptPath, args: launchArgs });
601
+
602
+ } // end for (per-task loop)
603
+ } // end processing block
298
604
 
299
605
  // ── 4. Acknowledge ───────────────────────────────────────────────────
606
+ // Single acknowledge path: always ack, even when nothing was processed or
607
+ // the per-poll cap deferred tasks. acknowledge_poll is idempotent (it only updates
608
+ // last_poll_at), so a partial ack followed by a later tick's ack is harmless.
300
609
  await agent(
301
610
  "Acknowledge the poll.\nCall artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"acknowledge_poll\", args {}.",
302
- { key: "ack-final", label: "Acknowledging poll", schema: { type: "object" } }
611
+ { key: "ack", label: "Acknowledging poll", schema: { type: "object" } }
303
612
  );
304
613
 
305
- var claimed = results.filter(function(r) { return r.action === "claimed"; });
614
+ var recommended = results.filter(function(r) { return r.action === "recommended"; });
306
615
  var completed = results.filter(function(r) { return r.action === "completed"; });
307
616
  var msg = "Dispatch complete.";
308
- if (claimed.length > 0) {
309
- msg += " Claimed: " + claimed.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
617
+ if (recommended.length > 0) {
618
+ msg += " Recommended: " + recommended.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
310
619
  }
311
620
  if (completed.length > 0) {
312
621
  msg += " Completed: " + completed.map(function(r) { return r.task_id; }).join(", ") + ".";
313
622
  }
314
- var skipped = results.filter(function(r) { return r.action === "skipped"; });
315
- if (skipped.length > 0) {
316
- msg += " Skipped (already claimed): " + skipped.length + ".";
317
- }
623
+ if (partial) { msg += " (partial: per-poll cap deferred tasks)"; }
624
+ if (eligible.length === 0) { msg = "No tasks ready."; }
318
625
 
319
- return { status: "ok", message: msg, claims: claimed };
626
+ return { status: "ok", message: msg, claims: recommended, partial: partial };