muse-crew 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ // Focused tests for the dispatcher's explicit retry-cap decision and the
2
+ // rejection budget. The retry policy consumes task.retry.consecutive_failures
3
+ // and task.retry.rejections_since_reset (explicit, dashboard-owned state)
4
+ // via decideRetry — no history projection, no event-message parsing. These
5
+ // tests extract decideRetry from the real crew-dispatch.js source and assert
6
+ // its behavior, plus structural properties of the dispatcher (the LLM history
7
+ // fetch and "[retry-cap]" marker machinery must be gone; parking must go
8
+ // through the atomic parktask action).
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ import assert from "node:assert/strict";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const src = readFileSync(join(here, "..", "crew-dispatch.js"), "utf8");
17
+
18
+ // Extract decideRetry's source and evaluate it in isolation.
19
+ const m = src.match(/function decideRetry\(consecutiveFailures, maxFailures\) \{[\s\S]*?\n\}/);
20
+ assert.ok(m, "decideRetry function not found in crew-dispatch.js");
21
+ const decideRetry = new Function(m[0] + "\nreturn decideRetry;")();
22
+
23
+ // Behavior: park at the cap, retry below it.
24
+ assert.equal(decideRetry(0, 3), "retry");
25
+ assert.equal(decideRetry(1, 3), "retry");
26
+ assert.equal(decideRetry(2, 3), "retry");
27
+ assert.equal(decideRetry(3, 3), "park");
28
+ assert.equal(decideRetry(4, 3), "park");
29
+ assert.equal(decideRetry(10, 3), "park");
30
+
31
+ // Behavior: fail open on missing/invalid state — an older dashboard
32
+ // (no retry field) must never strand a task.
33
+ assert.equal(decideRetry(undefined, 3), "retry");
34
+ assert.equal(decideRetry(null, 3), "retry");
35
+ assert.equal(decideRetry("3", 3), "retry");
36
+ assert.equal(decideRetry(NaN, 3), "retry");
37
+ assert.equal(decideRetry(-1, 3), "retry");
38
+
39
+ // Behavior: cap of 1 parks on the first failure.
40
+ assert.equal(decideRetry(0, 1), "retry");
41
+ assert.equal(decideRetry(1, 1), "park");
42
+
43
+ // Behavior: a non-positive cap disables the cap entirely — decideRetry
44
+ // owns this, not the caller. (Regression: the old RETRY_CAP_ENABLED gate
45
+ // meant decideRetry(5, 0) wrongly returned "park".)
46
+ assert.equal(decideRetry(0, 0), "retry");
47
+ assert.equal(decideRetry(5, 0), "retry");
48
+ assert.equal(decideRetry(5, -1), "retry");
49
+ assert.equal(decideRetry(100, -10), "retry");
50
+
51
+ // Structure: the old machinery is gone.
52
+ assert.ok(!src.includes("function countConsecutiveFailures"),
53
+ "countConsecutiveFailures must be deleted");
54
+ assert.ok(!src.includes("park_after"),
55
+ "park_after watermark map must be deleted");
56
+ assert.ok(!src.includes("getState"),
57
+ "LLM-mediated getState history fetch must be deleted");
58
+ assert.ok(!src.includes("RETRY_CAP_ENABLED"),
59
+ "RETRY_CAP_ENABLED gate must be deleted (decideRetry owns the disabled-cap case)");
60
+ assert.ok(!/\[retry-cap\]/.test(src.replace(/\/\/.*$/gm, "")),
61
+ "[retry-cap] marker must not be written (comment mentions are fine)");
62
+
63
+ // Structure: the dispatcher reads the explicit fields.
64
+ assert.ok(src.includes("rc.task.retry"),
65
+ "dispatcher must read task.retry from getdispatchstate");
66
+ assert.ok(src.includes("consecutive_failures"),
67
+ "dispatcher must consume consecutive_failures");
68
+ assert.ok(src.includes("rejections_since_reset"),
69
+ "dispatcher must consume rejections_since_reset");
70
+ assert.ok(src.includes("maxConsecutiveRejections"),
71
+ "dispatcher must read the maxConsecutiveRejections config key");
72
+ assert.ok(src.includes("rejectionCandidates"),
73
+ "dispatcher must budget rejection candidates before re-dispatching to rework");
74
+
75
+ // Structure: parking goes through the atomic parktask action — the park
76
+ // batch must not do the two-step updatetask+logevent dance.
77
+ const parkBatch = src.match(/\/\/ Batch all parks into one agent call[\s\S]*?^}/m);
78
+ assert.ok(parkBatch, "park batch block not found");
79
+ assert.ok(parkBatch[0].includes('action \\"parktask\\"'),
80
+ "dispatcher park batch must use the atomic parktask action");
81
+ assert.ok(!parkBatch[0].includes('action \\"updatetask\\"') && !parkBatch[0].includes('action \\"logevent\\"'),
82
+ "dispatcher park batch must not do the two-step updatetask+logevent dance");
83
+
84
+ // Structure: no workflow authors {status:"blocked"} anymore; each
85
+ // workflow parks through the atomic parktask action with a safety net.
86
+ for (const wf of ["standard.js", "bugfix.js", "chore.js", "docs.js"]) {
87
+ const wsrc = readFileSync(join(here, "..", wf), "utf8");
88
+ assert.ok(!wsrc.includes('{ status: "blocked"'),
89
+ `${wf} must not author {status:"blocked"}`);
90
+ assert.ok(wsrc.includes("async function parkTask(reason)"),
91
+ `${wf} must define parkTask`);
92
+ const parkFn = wsrc.match(/async function parkTask\(reason\) \{[\s\S]*?\n\}/);
93
+ assert.ok(parkFn, `${wf} parkTask not found`);
94
+ assert.ok(parkFn[0].includes('action \\"parktask\\"'),
95
+ `${wf} parkTask must use the atomic parktask action`);
96
+ assert.ok(!parkFn[0].includes('action \\"logevent\\"') && !parkFn[0].includes('action \\"updatetask\\"'),
97
+ `${wf} parkTask must not do the two-step logevent+updatetask dance (parktask is atomic)`);
98
+ assert.ok(/catch \(parkErr\)/.test(parkFn[0]),
99
+ `${wf} parkTask must catch a throwing park call and report failed, never lose the park silently`);
100
+ }
101
+
102
+ console.log("retry-cap tests: all assertions passed");
@@ -0,0 +1,122 @@
1
+ // Regression tests for honest work-agent failure classification.
2
+ //
3
+ // The workflow records per-attempt evidence (threw vs returned-empty, error
4
+ // text, durations) in the session notes when a work agent yields no usable
5
+ // report. These tests extract describeWorkAgentFailure and
6
+ // buildTransportRetryTrailer from the real workflow sources and assert the
7
+ // classification behavior — the same extract-and-evaluate pattern as
8
+ // retry-cap.test.mjs. All four workflows carrying the logic (standard,
9
+ // bugfix, chore, docs) are tested so the duplicated copies cannot drift.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+ import assert from "node:assert/strict";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const files = ["standard.js", "bugfix.js", "chore.js", "docs.js"];
18
+
19
+ function extract(src, name, params) {
20
+ // Brace-counting extraction: find "function <name>(<params>) {" and scan
21
+ // to the matching close brace. Robust against inner blocks; the regex
22
+ // approach stops at the first inner "};" line.
23
+ const startRe = new RegExp("[ ]*function " + name + "\\(" + params + "\\) \\{");
24
+ const m = src.match(startRe);
25
+ assert.ok(m, name + " function not found");
26
+ const start = m.index;
27
+ let depth = 0;
28
+ let i = start + m[0].length - 1; // at the opening brace
29
+ for (; i < src.length; i++) {
30
+ if (src[i] === "{") depth++;
31
+ else if (src[i] === "}") {
32
+ depth--;
33
+ if (depth === 0) return src.slice(start, i + 1);
34
+ }
35
+ }
36
+ assert.fail(name + ": unbalanced braces");
37
+ }
38
+
39
+ for (const file of files) {
40
+ const src = readFileSync(join(here, "..", file), "utf8");
41
+
42
+ const describeWorkAgentFailure = new Function(
43
+ extract(src, "describeWorkAgentFailure", "stepName, identity, attempts") +
44
+ "\nreturn describeWorkAgentFailure;"
45
+ )();
46
+
47
+ // Case 1: agent() threw on every attempt — the runtime discarded the
48
+ // output. The notes must say the output was discarded (not "produced no
49
+ // output"), carry the surviving error text, and carry per-attempt
50
+ // evidence. The old false claim ("preserved in run record") must be gone.
51
+ {
52
+ const attempts = [
53
+ { threw: true, error: "JSON parse failed near '{/*'", outcome: "", ms: 45231 },
54
+ { threw: true, error: "JSON parse failed near '{/*'", outcome: "", ms: 38900 },
55
+ { threw: true, error: "JSON parse failed near '{/*'", outcome: "", ms: 51200 },
56
+ ];
57
+ const r = describeWorkAgentFailure("Build", "wren", attempts);
58
+ assert.ok(r.notes.includes("no machine-readable report"), file + ": throw case notes");
59
+ assert.ok(r.notes.includes("runtime discarded the output"), file + ": throw case says discarded");
60
+ assert.ok(r.notes.includes("JSON parse failed"), file + ": throw case keeps error text");
61
+ assert.ok(r.notes.includes("attempt 1/3"), file + ": throw case numbers attempts");
62
+ assert.ok(r.notes.includes("45231ms"), file + ": throw case records durations");
63
+ assert.ok(!r.notes.includes("preserved in run record"), file + ": false preservation claim gone from notes");
64
+ assert.ok(!r.message.includes("preserved in run record"), file + ": false preservation claim gone from message");
65
+ assert.ok(r.eventMessage.includes("dispatcher will retry"), file + ": throw case stays retryable");
66
+ assert.ok(r.blockedReason.includes("3 attempts"), file + ": throw case blocked reason counts attempts");
67
+ }
68
+
69
+ // Case 2: agent() returned empty without throwing — the child produced
70
+ // nothing. The notes must not claim a throw/discarded output; they record
71
+ // what each attempt returned and how long it took.
72
+ {
73
+ const attempts = [
74
+ { threw: false, error: "", outcome: "blank string", ms: 11000 },
75
+ { threw: false, error: "", outcome: "null", ms: 9500 },
76
+ { threw: false, error: "", outcome: "blank string", ms: 10200 },
77
+ ];
78
+ const r = describeWorkAgentFailure("Build", "wren", attempts);
79
+ assert.ok(r.notes.includes("no usable output"), file + ": empty case notes");
80
+ assert.ok(!r.notes.includes("discarded"), file + ": empty case does not claim discard");
81
+ assert.ok(r.notes.includes("returned blank string"), file + ": empty case records outcome");
82
+ assert.ok(r.notes.includes("returned null"), file + ": empty case records null outcome");
83
+ assert.ok(r.notes.includes("11000ms"), file + ": empty case records durations");
84
+ assert.ok(r.eventMessage.includes("dispatcher will retry"), file + ": empty case stays retryable");
85
+ }
86
+
87
+ // Case 3: mixed attempts — any throw takes the discarded-output branch,
88
+ // and the detail covers every attempt.
89
+ {
90
+ const attempts = [
91
+ { threw: true, error: "boom", outcome: "", ms: 5000 },
92
+ { threw: false, error: "", outcome: "blank string", ms: 11000 },
93
+ ];
94
+ const r = describeWorkAgentFailure("QA", "hazel", attempts);
95
+ assert.ok(r.notes.includes("no machine-readable report"), file + ": mixed case takes throw branch");
96
+ assert.ok(r.notes.includes("threw 'boom'"), file + ": mixed case detail has the throw");
97
+ assert.ok(r.notes.includes("returned blank string"), file + ": mixed case detail has the empty");
98
+ assert.ok(r.notes.includes("attempt 2/2"), file + ": mixed case numbers against total");
99
+ }
100
+
101
+ // Case 4: the trailer names the failure mode of the previous attempt.
102
+ const buildTransportRetryTrailer = new Function(
103
+ extract(src, "buildTransportRetryTrailer", "stepName, repoPath, taskId, attempt, reason") +
104
+ "\nreturn buildTransportRetryTrailer;"
105
+ )();
106
+ {
107
+ const tEmpty = buildTransportRetryTrailer("Build", "/repo", "task-1", 1, "empty");
108
+ assert.ok(tEmpty.includes("no usable output"), file + ": empty trailer names empty failure");
109
+ assert.ok(tEmpty.includes("attempt 1 of 2"), file + ": trailer numbers the retry");
110
+ const tDiscarded = buildTransportRetryTrailer("Build", "/repo", "task-1", 2, "discarded");
111
+ assert.ok(tDiscarded.includes("could not be machine-read"), file + ": discarded trailer names discard");
112
+ const tDefault = buildTransportRetryTrailer("Build", "/repo", "task-1", 1);
113
+ assert.ok(tDefault.includes("could not be machine-read"), file + ": trailer defaults to discard wording");
114
+ }
115
+
116
+ // Source-level: the old vocabulary is gone everywhere.
117
+ assert.ok(!src.includes("preserved in run record"), file + ": no false preservation claim in source");
118
+ assert.ok(!src.includes("workerTransportError"), file + ": single-error variable replaced by attempts array");
119
+ assert.ok(!src.includes("Work agent failed with no output"), file + ": undifferentiated message gone");
120
+ }
121
+
122
+ console.log("work-agent-failure: all assertions passed for " + files.join(", "));