muse-crew 0.6.6 → 0.6.7

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,162 @@
1
+ // Regression tests for the read-board prompt/schema contradiction fix
2
+ // (dispatcher 2026-09-12 13:21 PDT tick).
3
+ //
4
+ // The read-board prompt told the agent to return the stdout "verbatim,
5
+ // byte-for-byte" (a string) while the agent-call schema demanded a
6
+ // top-level object with ready_tasks. The LLM resolved the contradiction
7
+ // non-deterministically: some ticks returned the parsed object and
8
+ // validated, others returned the raw string (or an envelope) and burned
9
+ // retries until the session blocked.
10
+ //
11
+ // The mechanical fix: the read-board agent() call carries NO schema — the
12
+ // agent ferries the raw stdout string (parsing forbidden in the prompt) and
13
+ // the workflow parses deterministically. The contract moved from the
14
+ // agent's formatting choice to the deterministic parse, which must:
15
+ //
16
+ // - converge every return shape (raw string, parsed object, envelope
17
+ // string, envelope object) onto the same boardData,
18
+ // - fail closed (throw) on invalid JSON, non-JSON returns, and envelopes
19
+ // with no ready_tasks — never a silent empty task set,
20
+ // - read projects from the normalized boardData (the old code read
21
+ // boardResult.projects from the raw return, which broke when the return
22
+ // was a string).
23
+ //
24
+ // These tests extract parseBoardJson and unwrapBoardResult from the real
25
+ // crew-dispatch.js source and assert the behavior, plus structural pins on
26
+ // the call site. Same extract-and-evaluate pattern as
27
+ // work-agent-failure.test.mjs.
28
+
29
+ import { readFileSync } from "node:fs";
30
+ import { fileURLToPath } from "node:url";
31
+ import { dirname, join } from "node:path";
32
+ import assert from "node:assert/strict";
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const DISPATCH = join(here, "..", "crew-dispatch.js");
36
+
37
+ function extract(src, name, params) {
38
+ // Brace-counting extraction: find "function <name>(<params>) {" and scan
39
+ // to the matching close brace. Robust against inner blocks.
40
+ const startRe = new RegExp("[ ]*function " + name + "\\(" + params + "\\) \\{");
41
+ const m = src.match(startRe);
42
+ assert.ok(m, name + " function not found");
43
+ const start = m.index;
44
+ let depth = 0;
45
+ let i = start + m[0].length - 1; // at the opening brace
46
+ for (; i < src.length; i++) {
47
+ if (src[i] === "{") depth++;
48
+ else if (src[i] === "}") {
49
+ depth--;
50
+ if (depth === 0) return src.slice(start, i + 1);
51
+ }
52
+ }
53
+ assert.fail("unbalanced braces in " + name);
54
+ }
55
+
56
+ const source = readFileSync(DISPATCH, "utf8");
57
+ const parseBoardJson = new Function(
58
+ "return (" + extract(source, "parseBoardJson", "boardReturn") + ")"
59
+ )();
60
+ const unwrapBoardResult = new Function(
61
+ "return (" + extract(source, "unwrapBoardResult", "boardResult") + ")"
62
+ )();
63
+
64
+ // The full deterministic pipeline the dispatcher runs on the agent return.
65
+ function converge(boardReturn) {
66
+ return unwrapBoardResult(parseBoardJson(boardReturn));
67
+ }
68
+
69
+ const TASKS = [
70
+ { id: "task-1", title: "First", state: "todo" },
71
+ { id: "task-2", title: "Second", state: "todo" }
72
+ ];
73
+ const PROJECTS = [{ id: "orchestra-dashboard" }, { id: "muse-crew" }];
74
+ const BOARD = { ready_tasks: TASKS, projects: PROJECTS, config: {}, counts: {} };
75
+
76
+ let pass = 0, fail = 0;
77
+ function ok(name, fn) {
78
+ try {
79
+ fn();
80
+ pass++;
81
+ console.log("PASS: " + name);
82
+ } catch (e) {
83
+ fail++;
84
+ console.log("FAIL: " + name + " — " + e.message);
85
+ }
86
+ }
87
+ function throwsClosed(name, fn) {
88
+ ok(name, () => {
89
+ assert.throws(fn, (e) => e instanceof Error && e.message.length > 0,
90
+ "expected a fail-closed throw, got none");
91
+ });
92
+ }
93
+
94
+ // ── Convergent return shapes ─────────────────────────────────────────
95
+ ok("raw stdout string converges to boardData", () => {
96
+ const boardData = converge(JSON.stringify(BOARD));
97
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
98
+ assert.deepStrictEqual(boardData.projects, PROJECTS);
99
+ });
100
+
101
+ ok("parsed object return converges to the same boardData", () => {
102
+ const boardData = converge(BOARD);
103
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
104
+ assert.deepStrictEqual(boardData.projects, PROJECTS);
105
+ });
106
+
107
+ ok("envelope string {\"data\": {...}} converges", () => {
108
+ const boardData = converge(JSON.stringify({ data: BOARD }));
109
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
110
+ });
111
+
112
+ ok("envelope object {result: {ready_tasks}} converges", () => {
113
+ const boardData = converge({ result: { ready_tasks: TASKS } });
114
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
115
+ });
116
+
117
+ ok("envelope object {stdout: json-string} converges", () => {
118
+ const boardData = converge({ stdout: JSON.stringify(BOARD) });
119
+ assert.deepStrictEqual(boardData.ready_tasks, TASKS);
120
+ });
121
+
122
+ ok("string and object returns converge identically (deep equal)", () => {
123
+ const fromString = converge(JSON.stringify(BOARD));
124
+ const fromObject = converge(JSON.parse(JSON.stringify(BOARD)));
125
+ assert.deepStrictEqual(fromString, fromObject);
126
+ });
127
+
128
+ // ── Fail-closed: never a silent empty task set ──────────────────────
129
+ throwsClosed("invalid JSON string throws", () => converge("{not json"));
130
+ throwsClosed("truncated JSON throws", () => converge('{"ready_tasks": ['));
131
+ throwsClosed("null throws", () => converge(null));
132
+ throwsClosed("undefined throws", () => converge(undefined));
133
+ throwsClosed("number throws", () => converge(42));
134
+ throwsClosed("empty string throws", () => converge(""));
135
+ throwsClosed("valid JSON without ready_tasks throws", () => converge(JSON.stringify({ foo: 1 })));
136
+ throwsClosed("envelope without ready_tasks throws", () => converge(JSON.stringify({ data: {} })));
137
+
138
+ // ── Structural pins on the call site ────────────────────────────────
139
+ ok("read-board agent call exists", () => {
140
+ assert.ok(source.includes('key: "read-board"'), "key marker missing");
141
+ });
142
+ ok("read-board agent call carries no schema", () => {
143
+ const keyIdx = source.indexOf('key: "read-board"');
144
+ const nextAgentIdx = source.indexOf("await agent(", keyIdx + 1);
145
+ const callWindow = nextAgentIdx === -1 ? source.slice(keyIdx) : source.slice(keyIdx, nextAgentIdx);
146
+ assert.ok(!callWindow.includes("schema:"), "schema found on read-board call");
147
+ });
148
+ ok("read-board prompt demands a raw string and forbids parsing", () => {
149
+ assert.ok(source.includes("Do NOT parse the JSON"),
150
+ "prompt must forbid parsing");
151
+ assert.ok(source.includes("byte-for-byte, unmodified"),
152
+ "prompt must demand verbatim ferry");
153
+ });
154
+ ok("projects is read from normalized boardData, not the raw return", () => {
155
+ assert.ok(source.includes("const projects = boardData.projects"),
156
+ "projects must come from boardData (raw return may be a string)");
157
+ assert.ok(!source.includes("boardResult.projects"),
158
+ "stale boardResult.projects read must be gone");
159
+ });
160
+
161
+ console.log("\n" + pass + " passed, " + fail + " failed.");
162
+ process.exit(fail > 0 ? 1 : 0);
@@ -1,287 +0,0 @@
1
- #!/usr/bin/env bash
2
- # orphan-sweep.sh — find and clean orphaned worktrees, stale merge locks,
3
- # and stale lifecycle pins
4
- #
5
- # Usage:
6
- # orphan-sweep.sh report — list orphans (read-only for worktrees);
7
- # expired-lease locks are released
8
- # orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only),
9
- # release expired-lease merge locks, and remove pins whose
10
- # task has no running session
11
- #
12
- # Active-run knowledge is injected by the caller, never fetched here:
13
- # CREW_ACTIVE_TASKS — space-separated task IDs with a running session
14
- # CREW_ACTIVE_TASKS_FILE — path to a file with one task ID per line
15
- # (blank lines and '#' comments are ignored)
16
- # Both inputs feed one active set, matched exactly against worktree dir names
17
- # and pin dir names. Both inputs must contain full task UUIDs (lowercase
18
- # 8-4-4-4-12 hex, e.g. 7dbe861d-cddd-479f-87e9-9423539c08a3): clean mode
19
- # validates every entry against that format and exits 2 with a BLOCKED line
20
- # on any malformed entry, because a truncated or reformatted ID would never
21
- # match exactly and would silently defeat the never-touch-active-runs
22
- # promise.
23
- # A task in the active set is never touched: its worktree, any merge lock
24
- # it holds, and its lifecycle pin are skipped regardless of merge status,
25
- # lock age, or PID liveness.
26
- #
27
- # Lifecycle pins live on persistent disk ($CREW_HOME/.pins/<task>) so a VM
28
- # reboot (which wipes tmpfs /tmp) can't strand a run mid-flight. A pin whose
29
- # task has no running session is safe to remove: any new run re-pins
30
- # idempotently (mkdir -p + cp) before use.
31
- #
32
- # Restart-fragility guard (bug a8bf78ca): pin reaping requires REAP_AFTER
33
- # (default 3, override via ORPHAN_SWEEP_REAP_AFTER) consecutive clean-mode
34
- # observations with valid active-run data showing the task absent. A single
35
- # observation never reaps a pin — a daemon restart can make a live task's
36
- # session read as not-running for one observation window. A first absence
37
- # only plants a tombstone marker ($CREW_HOME/.pins/.sweep-suspects/<task>);
38
- # a task present in the active set is never touched and clears its marker.
39
- # Report mode and BLOCKED clean (no active-run data) never touch markers.
40
- #
41
- # Fail closed: clean mode without either input refuses to remove anything
42
- # and exits 2. Report mode without either input still runs read-only but
43
- # prints a banner noting the ACTIVE checks were skipped.
44
- #
45
- # Callers (the sweep cron body; worktree-lifecycle.sh cmd_sweep is a
46
- # passthrough) must export CREW_ACTIVE_TASKS.
47
- #
48
- # "Safe to clean" means the task branch is fully merged into main.
49
- # Dirty or unmerged worktrees are always preserved and reported.
50
-
51
- set -euo pipefail
52
-
53
- REPO="${CREW_REPO:-$HOME/workspace/ts-spaces/orchestra-dashboard}"
54
- WORKTREE_DIR="$REPO/.worktrees"
55
- LOCK_FILE="$WORKTREE_DIR/.merge-lock"
56
- MERGE_LOCK_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-lock.sh"
57
-
58
- cmd="${1:-report}"
59
- found=0
60
- any_failed=0
61
-
62
- # --- Active-run set (union of both inputs) ---
63
- have_active_data=0
64
- ACTIVE_TASKS=""
65
- if [ -n "${CREW_ACTIVE_TASKS+set}" ]; then
66
- have_active_data=1
67
- ACTIVE_TASKS="${ACTIVE_TASKS} ${CREW_ACTIVE_TASKS}"
68
- fi
69
- if [ -n "${CREW_ACTIVE_TASKS_FILE+set}" ]; then
70
- if [ -r "${CREW_ACTIVE_TASKS_FILE}" ]; then
71
- have_active_data=1
72
- while IFS= read -r line || [ -n "$line" ]; do
73
- case "$line" in ''|\#*) continue ;; esac
74
- ACTIVE_TASKS="${ACTIVE_TASKS} ${line}"
75
- done < "${CREW_ACTIVE_TASKS_FILE}"
76
- fi
77
- fi
78
-
79
- task_is_active() {
80
- [ -n "${1:-}" ] || return 1
81
- case " ${ACTIVE_TASKS} " in
82
- *" $1 "*) return 0 ;;
83
- *) return 1 ;;
84
- esac
85
- }
86
-
87
- # Fail closed on malformed active-run IDs: the guard below matches entries
88
- # exactly against full-UUID directory names, branch names, and merge-lock
89
- # holders. A short/truncated/reformatted ID would never match and would
90
- # silently defeat the never-touch-active-runs promise. No prefix or fuzzy
91
- # matching — ambiguous prefixes could match multiple runs; refusing is
92
- # the correct behavior.
93
- UUID_RE='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
94
- if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 1 ]; then
95
- for entry in $ACTIVE_TASKS; do
96
- if [[ ! "$entry" =~ $UUID_RE ]]; then
97
- echo "BLOCKED: CREW_ACTIVE_TASKS entry '$entry' is not a full task UUID (expected lowercase 8-4-4-4-12 hex) — refusing to clean"
98
- exit 2
99
- fi
100
- done
101
- fi
102
-
103
- if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 0 ]; then
104
- echo "BLOCKED: clean mode requires the active-run list (set CREW_ACTIVE_TASKS or CREW_ACTIVE_TASKS_FILE) — refusing to remove anything"
105
- exit 2
106
- fi
107
-
108
- if [ "$cmd" != "clean" ] && [ "$have_active_data" -eq 0 ]; then
109
- echo "NOTE: no active-run data provided; ACTIVE checks skipped"
110
- fi
111
-
112
- # --- Stale merge lock (lease-based; bug 2fc8f52f) ---
113
- # Owner identity comes from merge-lock.sh status key=value fields — never
114
- # from a PID. Fail-closed: a lock whose task has a running session is
115
- # untouched regardless of lease age; a lock whose task has no running
116
- # session is broken only when its lease is expired. The break goes through
117
- # merge-lock.sh release (or force-release for an unparseable file with no
118
- # identifiable holder) so it lands in the merge-lock audit log.
119
- if [ -f "$LOCK_FILE" ]; then
120
- lock_status="$("$MERGE_LOCK_SCRIPT" status 2>/dev/null || echo UNLOCKED)"
121
- if [ "$lock_status" = "UNLOCKED" ]; then
122
- : # lock file vanished between the check and the read — nothing held
123
- else
124
- lock_holder=$(printf '%s\n' "$lock_status" | sed -n 's/^task_id=//p' | head -1)
125
- lock_remaining=$(printf '%s\n' "$lock_status" | sed -n 's/^remaining_seconds=//p' | head -1)
126
- lock_age=$(printf '%s\n' "$lock_status" | sed -n 's/^age_seconds=//p' | head -1)
127
- if task_is_active "$lock_holder"; then
128
- echo "ACTIVE_LOCK: held by ${lock_holder:-unknown} (active run on dashboard) — skipping"
129
- found=1
130
- elif [ "${lock_remaining:-unknown}" != "unknown" ] && [ "$lock_remaining" -gt 0 ]; then
131
- echo "HELD_LOCK: held by ${lock_holder:-unknown} (lease ${lock_remaining}s remaining) — skipping"
132
- found=1
133
- else
134
- echo "STALE_LOCK: held by ${lock_holder:-unknown} (age ${lock_age:-unknown}s, lease expired, no running session)"
135
- found=1
136
- if [ -n "$lock_holder" ]; then
137
- rel_out=$("$MERGE_LOCK_SCRIPT" release "$lock_holder" 2>&1) || true
138
- else
139
- rel_out=$("$MERGE_LOCK_SCRIPT" force-release 2>&1) || true
140
- fi
141
- case "$rel_out" in
142
- *RELEASED*)
143
- echo " → released (lease expired)"
144
- ;;
145
- *)
146
- echo " → FAILED: could not release lock: $rel_out"
147
- any_failed=1
148
- ;;
149
- esac
150
- fi
151
- fi
152
- fi
153
-
154
- # --- Prune git worktree metadata ---
155
- cd "$REPO"
156
- git worktree prune 2>/dev/null
157
-
158
- # --- Scan worktree directories ---
159
- if [ -d "$WORKTREE_DIR" ]; then
160
- for wt in "$WORKTREE_DIR"/*/; do
161
- [ -d "$wt" ] || continue
162
- task_id=$(basename "$wt")
163
-
164
- # Skip hidden dirs and lock files
165
- [[ "$task_id" == .* ]] && continue
166
-
167
- branch="task/$task_id"
168
-
169
- found=1
170
-
171
- # Active runs are never touched, regardless of merge status.
172
- if task_is_active "$task_id"; then
173
- echo "ACTIVE: $task_id — dashboard shows a running session, skipped"
174
- continue
175
- fi
176
-
177
- # Is the branch merged into main?
178
- merged=$(git branch --merged main 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
179
-
180
- # Is the worktree dirty?
181
- dirty=$(cd "$wt" && git status --porcelain 2>/dev/null | wc -l)
182
-
183
- if [ -n "$merged" ]; then
184
- echo "MERGED: $task_id — branch merged into main, safe to remove"
185
- if [ "$cmd" = "clean" ]; then
186
- git worktree remove "$wt" 2>/dev/null || true
187
- git branch -d "$branch" 2>/dev/null || true
188
- if [ ! -d "$wt" ] && ! git show-ref --verify --quiet "refs/heads/$branch"; then
189
- echo " → removed"
190
- else
191
- echo " → FAILED: removal attempted but worktree/branch still present, left in place"
192
- any_failed=1
193
- fi
194
- fi
195
- elif [ "$dirty" -gt 0 ]; then
196
- echo "DIRTY: $task_id — $dirty uncommitted changes, preserved"
197
- else
198
- echo "UNMERGED: $task_id — committed work not yet merged, preserved"
199
- fi
200
- done
201
- fi
202
-
203
- # --- Reap stale lifecycle pins (restart-fragility guard; bug a8bf78ca) ---
204
- # A pin whose task is absent from the active set is reaped only after
205
- # REAP_AFTER consecutive clean-mode observations with valid active-run
206
- # data still show it absent. Counters only — no wall clock.
207
- PINS_DIR="${CREW_HOME:-$HOME/workspace/.jarvis}/.pins"
208
- SUSPECTS_DIR="$PINS_DIR/.sweep-suspects"
209
- REAP_AFTER="${ORPHAN_SWEEP_REAP_AFTER:-3}"
210
- if [ "$cmd" = "clean" ]; then
211
- if ! [[ "$REAP_AFTER" =~ ^[1-9][0-9]*$ ]]; then
212
- echo "BLOCKED: ORPHAN_SWEEP_REAP_AFTER='$REAP_AFTER' is not a positive integer — refusing to clean"
213
- exit 2
214
- fi
215
- fi
216
- if [ -d "$PINS_DIR" ]; then
217
- for pin in "$PINS_DIR"/*/; do
218
- [ -d "$pin" ] || continue
219
- task_id=$(basename "$pin")
220
-
221
- # Skip hidden dirs (the .sweep-suspects dir lives under PINS_DIR)
222
- [[ "$task_id" == .* ]] && continue
223
-
224
- found=1
225
-
226
- # Active runs are never touched; a pending tombstone is stale.
227
- if task_is_active "$task_id"; then
228
- if [ -f "$SUSPECTS_DIR/$task_id" ]; then
229
- rm -f "$SUSPECTS_DIR/$task_id"
230
- fi
231
- echo "ACTIVE_PIN: $task_id — dashboard shows a running session, kept"
232
- continue
233
- fi
234
-
235
- # Clean mode: count consecutive negative observations; reap only after
236
- # REAP_AFTER of them. Report mode stays read-only and never touches
237
- # markers — only clean-mode observations count.
238
- if [ "$cmd" = "clean" ]; then
239
- count=0
240
- marker="$SUSPECTS_DIR/$task_id"
241
- if [ -f "$marker" ]; then
242
- count=$(cat "$marker" 2>/dev/null || echo 0)
243
- [[ "$count" =~ ^[0-9]+$ ]] || count=0
244
- fi
245
- count=$((count + 1))
246
- if [ "$count" -lt "$REAP_AFTER" ]; then
247
- mkdir -p "$SUSPECTS_DIR"
248
- printf '%s\n' "$count" > "$marker"
249
- echo "SUSPECT_PIN: $task_id — no running session (observation $count/$REAP_AFTER), kept for now"
250
- continue
251
- fi
252
- echo "STALE_PIN: $task_id — no running session for $REAP_AFTER consecutive observations, removing"
253
- rm -rf "$pin"
254
- rm -f "$marker"
255
- if [ ! -d "$pin" ]; then
256
- echo " → removed"
257
- else
258
- echo " → FAILED: pin still present after rm, left in place"
259
- any_failed=1
260
- fi
261
- continue
262
- fi
263
-
264
- echo "STALE_PIN: $task_id — no running session, safe to remove"
265
- done
266
-
267
- # Prune tombstones whose pin dir no longer exists (e.g. a run cleaned up
268
- # its own pin). Deleting state is a clean-mode action only.
269
- if [ "$cmd" = "clean" ] && [ -d "$SUSPECTS_DIR" ]; then
270
- for marker in "$SUSPECTS_DIR"/*; do
271
- [ -f "$marker" ] || continue
272
- marker_task=$(basename "$marker")
273
- if [ ! -d "$PINS_DIR/$marker_task" ]; then
274
- rm -f "$marker"
275
- fi
276
- done
277
- fi
278
- fi
279
-
280
- if [ "$found" -eq 0 ]; then
281
- echo "CLEAN: no orphans, no stale locks, no stale pins"
282
- fi
283
-
284
- if [ "$any_failed" -eq 1 ]; then
285
- exit 1
286
- fi
287
- exit 0