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.
- 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 +38 -14
- package/lib/test-merge-lock.sh +69 -0
- package/lib/test-orphan-sweep.sh +74 -8
- package/lib/test-publish-verify.sh +157 -0
- package/lib/test-version-write.sh +102 -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 -173
- package/workflows/chore.js +888 -146
- package/workflows/crew-dispatch.js +405 -98
- package/workflows/crew-init.js +4 -35
- package/workflows/docs.js +342 -22
- package/workflows/standard.js +1024 -172
- package/workflows/tests/retry-cap.test.mjs +102 -0
- package/workflows/tests/work-agent-failure.test.mjs +122 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const meta = {
|
|
2
2
|
name: "crew-dispatch",
|
|
3
|
-
description: "Thin Muse Crew dispatcher — reads the board,
|
|
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
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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("
|
|
296
|
-
results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "
|
|
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
|
|
611
|
+
{ key: "ack", label: "Acknowledging poll", schema: { type: "object" } }
|
|
303
612
|
);
|
|
304
613
|
|
|
305
|
-
var
|
|
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 (
|
|
309
|
-
msg += "
|
|
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
|
-
|
|
315
|
-
if (
|
|
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:
|
|
626
|
+
return { status: "ok", message: msg, claims: recommended, partial: partial };
|