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.
- package/AGENTS.md +1 -1
- package/API.md +53 -9
- package/docs/guide.md +9 -6
- package/docs/publish-verification.md +205 -0
- package/lib/AGENTS.md +5 -5
- package/lib/crew-api.js +1101 -0
- package/lib/crew-release.sh +19 -0
- package/lib/merge-lock.sh +18 -5
- package/lib/publish-npm.sh +6 -5
- package/lib/schema.sql +101 -0
- package/lib/test-merge-lock.sh +13 -0
- package/lib/test-publish-skip.sh +2 -1
- package/lib/test-terminal-cleanup.sh +131 -0
- package/lib/test-verify-merge.sh +1 -0
- package/lib/test-worktree-backend.sh +74 -0
- package/lib/worktree-lifecycle.sh +164 -16
- package/package.json +2 -2
- package/seed/AGENTS.md +0 -1
- package/seed/crons.json +0 -13
- package/workflows/AGENTS.md +4 -2
- package/workflows/bugfix.js +987 -193
- package/workflows/chore.js +936 -177
- package/workflows/crew-dispatch.js +201 -37
- package/workflows/crew-init.js +82 -54
- package/workflows/docs.js +117 -48
- package/workflows/standard.js +970 -195
- package/workflows/tests/read-board-parse.test.mjs +162 -0
- package/lib/orphan-sweep.sh +0 -287
- package/lib/test-orphan-sweep.sh +0 -276
- package/seed/cron-sweep-body-template.md +0 -11
|
@@ -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);
|
package/lib/orphan-sweep.sh
DELETED
|
@@ -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
|