@mmerterden/multi-agent-pipeline 16.31.0 → 17.0.0
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/CHANGELOG.md +88 -0
- package/README.md +101 -101
- package/README.tr.md +101 -101
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/adr/0012-macos-only.md +98 -0
- package/docs/adr/README.md +1 -0
- package/docs/engineering.md +1 -1
- package/index.js +26 -0
- package/install/_dev-only-files.mjs +0 -1
- package/install/index.mjs +10 -0
- package/package.json +5 -3
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
- package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
- package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +26 -21
- package/pipeline/lib/account-resolver.sh +1 -1
- package/pipeline/lib/stack-detect.sh +200 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +29 -10
- package/pipeline/multi-agent-refs/features/doctor.md +15 -3
- package/pipeline/multi-agent-refs/features/visual-evidence.md +49 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +6 -4
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
- package/pipeline/multi-agent-refs/rules.md +4 -5
- package/pipeline/schemas/agent-state.schema.json +99 -25
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/_stack-routing.mjs +91 -0
- package/pipeline/scripts/capture-resume.sh +76 -14
- package/pipeline/scripts/doctor.mjs +26 -3
- package/pipeline/scripts/gc-abandoned.sh +352 -0
- package/pipeline/scripts/keychain.py +1 -1
- package/pipeline/scripts/usage-report.mjs +5 -5
- package/pipeline/scripts/gate-linux.sh +0 -62
|
@@ -29,20 +29,70 @@ LOGS="$HOME/.claude/logs/multi-agent"
|
|
|
29
29
|
PIPELINE_MEM="$HOME/.claude/memory/multi-agent/_pipeline"
|
|
30
30
|
REVIEW_DAYS=7
|
|
31
31
|
|
|
32
|
+
# Three buckets, not one line. Measured on the development machine: 22 runs read
|
|
33
|
+
# `in_progress` and every one was over a day old, but they are not one failure.
|
|
34
|
+
# 13 stopped at Phase 0, which is almost entirely questions - those runs never
|
|
35
|
+
# started. 3 had their PR already open and were waiting at Phase 6/7, where the
|
|
36
|
+
# pipeline pauses ON PURPOSE for channel selection. 4 died mid-development.
|
|
37
|
+
#
|
|
38
|
+
# Reporting the newest one of those as "stopped at Phase N" told the truth about
|
|
39
|
+
# one run and hid twenty-one, and it read the same whether the pipeline was
|
|
40
|
+
# waiting for the user or had crashed. A finished job and a dead one should not
|
|
41
|
+
# share a sentence.
|
|
42
|
+
AWAITING_N=0; AWAITING_TASK=""; AWAITING_PHASE=""
|
|
43
|
+
DIED_N=0; DIED_TASK=""; DIED_PHASE=""
|
|
44
|
+
UNSTARTED_N=0; UNSTARTED_TASK=""
|
|
32
45
|
STALE_TASK=""
|
|
33
46
|
STALE_PHASE=""
|
|
34
47
|
|
|
35
48
|
if [ -d "$LOGS" ] && command -v jq >/dev/null 2>&1; then
|
|
36
|
-
# Newest
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
# Newest first, so the first hit in each bucket is the one worth naming.
|
|
50
|
+
# Both layouts are walked: state is written flat as <task-id>/ by
|
|
51
|
+
# phase-tracker.sh and nested as <project>/<task-id>/ by Phase 0, and a
|
|
52
|
+
# reader that knows only one of them under-reports by the size of the other.
|
|
53
|
+
while IFS= read -r f; do
|
|
54
|
+
[ -f "$f" ] || continue
|
|
55
|
+
STATUS=$(jq -r '.status // ""' "$f" 2>/dev/null)
|
|
56
|
+
# Only runs that CLAIM to be running are bucketed. A file with no status is
|
|
57
|
+
# unknown - eight of them here, six with no currentPhase either - and
|
|
58
|
+
# reporting unknown as "stopped mid-development" is the same false claim
|
|
59
|
+
# this change exists to remove, just pointed the other way. If that hides a
|
|
60
|
+
# real run, the fix is for its writer to record a status.
|
|
61
|
+
# `complete` is the schema spelling and `completed` is in the wild too, so
|
|
62
|
+
# both are excluded; a reader that knows one calls finished runs stale.
|
|
63
|
+
case "$STATUS" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
|
|
64
|
+
PHASE=$(jq -r '.currentPhase // "?"' "$f" 2>/dev/null)
|
|
65
|
+
TASK=$(jq -r '.taskId // .jiraId // ""' "$f" 2>/dev/null)
|
|
66
|
+
[ -n "$TASK" ] || continue
|
|
67
|
+
# `.pr` is an object in the schema and a bare URL string in three state files
|
|
68
|
+
# on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
|
|
69
|
+
# reads as "no PR" - so a run whose PR is already open would be classed as dead
|
|
70
|
+
# and reaped. Read the shape, do not assume it.
|
|
71
|
+
PR=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$f" 2>/dev/null)
|
|
72
|
+
|
|
73
|
+
# Waiting for you, not broken: an open PR means the work landed, and
|
|
74
|
+
# Phase 7 pauses for channel selection by design (modes.md).
|
|
75
|
+
if [ "$STATUS" = "awaiting_input" ] || [ -n "$PR" ] ||
|
|
76
|
+
[ "$PHASE" = "6" ] || [ "$PHASE" = "7" ]; then
|
|
77
|
+
AWAITING_N=$((AWAITING_N + 1))
|
|
78
|
+
[ -z "$AWAITING_TASK" ] && { AWAITING_TASK="$TASK"; AWAITING_PHASE="$PHASE"; }
|
|
79
|
+
elif [ "$PHASE" = "0" ]; then
|
|
80
|
+
# Phase 0 is almost all questions. Nothing was built, so there is nothing
|
|
81
|
+
# to resume and nothing to lose.
|
|
82
|
+
UNSTARTED_N=$((UNSTARTED_N + 1))
|
|
83
|
+
[ -z "$UNSTARTED_TASK" ] && UNSTARTED_TASK="$TASK"
|
|
84
|
+
else
|
|
85
|
+
DIED_N=$((DIED_N + 1))
|
|
86
|
+
[ -z "$DIED_TASK" ] && { DIED_TASK="$TASK"; DIED_PHASE="$PHASE"; }
|
|
45
87
|
fi
|
|
88
|
+
done <<EOF
|
|
89
|
+
$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null)
|
|
90
|
+
EOF
|
|
91
|
+
# Kept for the JSON consumers that read staleTask/stalePhase.
|
|
92
|
+
if [ -n "$AWAITING_TASK" ]; then
|
|
93
|
+
STALE_TASK="$AWAITING_TASK"; STALE_PHASE="$AWAITING_PHASE"
|
|
94
|
+
elif [ -n "$DIED_TASK" ]; then
|
|
95
|
+
STALE_TASK="$DIED_TASK"; STALE_PHASE="$DIED_PHASE"
|
|
46
96
|
fi
|
|
47
97
|
fi
|
|
48
98
|
|
|
@@ -71,14 +121,26 @@ if [ -d "$PIPELINE_MEM/observations" ]; then
|
|
|
71
121
|
fi
|
|
72
122
|
|
|
73
123
|
if [ "$JSON" -eq 1 ]; then
|
|
74
|
-
printf '{"staleTask":"%s","stalePhase":"%s","openObservations":%s,"reviewDue":%s}\n' \
|
|
75
|
-
"$STALE_TASK" "$STALE_PHASE" "${OPEN_OBS:-0}" "$REVIEW_DUE"
|
|
124
|
+
printf '{"staleTask":"%s","stalePhase":"%s","awaiting":%s,"died":%s,"unstarted":%s,"openObservations":%s,"reviewDue":%s}\n' \
|
|
125
|
+
"$STALE_TASK" "$STALE_PHASE" "$AWAITING_N" "$DIED_N" "$UNSTARTED_N" "${OPEN_OBS:-0}" "$REVIEW_DUE"
|
|
76
126
|
exit 0
|
|
77
127
|
fi
|
|
78
128
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
# One line per bucket, each naming its own next action, because they are
|
|
130
|
+
# different actions. Counts are printed even when they are 1: "1 run" and
|
|
131
|
+
# "13 runs" call for different reactions and the old line could not tell them
|
|
132
|
+
# apart.
|
|
133
|
+
if [ "$AWAITING_N" -gt 0 ]; then
|
|
134
|
+
printf 'multi-agent: %s run(s) waiting on you, oldest %s at Phase %s - `/multi-agent:resume` to finish.\n' \
|
|
135
|
+
"$AWAITING_N" "$AWAITING_TASK" "$AWAITING_PHASE"
|
|
136
|
+
fi
|
|
137
|
+
if [ "$DIED_N" -gt 0 ]; then
|
|
138
|
+
printf 'multi-agent: %s run(s) stopped mid-development, oldest %s at Phase %s - `/multi-agent:resume` or `/multi-agent:kill`.\n' \
|
|
139
|
+
"$DIED_N" "$DIED_TASK" "$DIED_PHASE"
|
|
140
|
+
fi
|
|
141
|
+
if [ "$UNSTARTED_N" -gt 0 ]; then
|
|
142
|
+
printf 'multi-agent: %s run(s) left at a Phase 0 question, nothing built - `/multi-agent:garbage-collect --abandoned` to clear.\n' \
|
|
143
|
+
"$UNSTARTED_N"
|
|
82
144
|
fi
|
|
83
145
|
if [ "$REVIEW_DUE" -eq 1 ]; then
|
|
84
146
|
printf 'multi-agent: %s open pipeline observation(s), last review %s+ days ago - `/multi-agent:refactor backlog` when convenient.\n' \
|
|
@@ -70,6 +70,7 @@ const TASK_TOOLS = taskFlag ? taskFlag.split("=")[1] : null;
|
|
|
70
70
|
// the two are equal in BOTH directions: a check cannot ship without its entry,
|
|
71
71
|
// and an entry cannot outlive its check.
|
|
72
72
|
const CHECK_IDS = [
|
|
73
|
+
"host-platform",
|
|
73
74
|
"install-present",
|
|
74
75
|
"install-version",
|
|
75
76
|
"script-surface",
|
|
@@ -87,10 +88,11 @@ const CHECK_IDS = [
|
|
|
87
88
|
"worktree-residue",
|
|
88
89
|
];
|
|
89
90
|
|
|
90
|
-
// Only these
|
|
91
|
+
// Only these six may return BLOCK. Enforced below, not merely documented: a
|
|
91
92
|
// check that returns BLOCK without being listed here is downgraded and the
|
|
92
93
|
// downgrade is reported, because an unenforced rule is a comment.
|
|
93
94
|
const MAY_BLOCK = new Set([
|
|
95
|
+
"host-platform",
|
|
94
96
|
"install-present",
|
|
95
97
|
"script-surface",
|
|
96
98
|
"state-writable",
|
|
@@ -153,6 +155,25 @@ function readJson(path) {
|
|
|
153
155
|
// one step silently fails is worse than no step. Rule: smoke-npm-scope-pinning.sh.
|
|
154
156
|
const SUBTREES = ["commands", "multi-agent-refs", "scripts", "lib", "schemas"];
|
|
155
157
|
|
|
158
|
+
// Runs before everything else so a non-darwin host gets one honest line instead
|
|
159
|
+
// of a cascade of failures whose common cause is never stated. This blocks
|
|
160
|
+
// rather than warns because a run there does not degrade, it fails: every
|
|
161
|
+
// credential read shells `security`, every iOS build `xcodebuild`, every piece
|
|
162
|
+
// of visual evidence `simctl`.
|
|
163
|
+
function checkHostPlatform() {
|
|
164
|
+
if (process.platform === "darwin") {
|
|
165
|
+
ok("host-platform");
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
report(
|
|
169
|
+
"host-platform",
|
|
170
|
+
"BLOCK",
|
|
171
|
+
`this host reports "${process.platform}"; the pipeline supports macOS only`,
|
|
172
|
+
"install on macOS, or set MULTI_AGENT_ALLOW_NON_DARWIN=1 to proceed untested",
|
|
173
|
+
);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
156
177
|
function checkInstallPresent() {
|
|
157
178
|
if (!existsSync(CLAUDE)) {
|
|
158
179
|
report(
|
|
@@ -718,18 +739,20 @@ function main() {
|
|
|
718
739
|
return;
|
|
719
740
|
}
|
|
720
741
|
|
|
742
|
+
checkHostPlatform();
|
|
743
|
+
|
|
721
744
|
const resolved = checkInstallPresent();
|
|
722
745
|
if (!resolved) {
|
|
723
746
|
// Indeterminate, not healthy and not merely degraded: nothing after this
|
|
724
747
|
// could be checked, and saying "blocked" would claim a verdict on checks
|
|
725
748
|
// that never ran.
|
|
726
|
-
const line = results[
|
|
749
|
+
const line = results[results.length - 1];
|
|
727
750
|
if (JSON_OUT) {
|
|
728
751
|
process.stdout.write(`${JSON.stringify({ exit: 4, checks: results }, null, 2)}\n`);
|
|
729
752
|
} else {
|
|
730
753
|
process.stdout.write(`${line.severity} ${line.id} - ${line.problem} - ${line.step}\n`);
|
|
731
754
|
process.stdout.write(
|
|
732
|
-
`\n-> indeterminate: the layout did not resolve, so ${CHECK_IDS.length -
|
|
755
|
+
`\n-> indeterminate: the layout did not resolve, so ${CHECK_IDS.length - results.length} checks did not run\n`,
|
|
733
756
|
);
|
|
734
757
|
}
|
|
735
758
|
process.exitCode = 4;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gc-abandoned.sh - reap runs that stopped and were never picked back up.
|
|
3
|
+
#
|
|
4
|
+
# The gap this fills: gc-worktrees.sh deliberately skips REGISTERED worktrees,
|
|
5
|
+
# because a registered worktree belongs to a live run. That is right, and it
|
|
6
|
+
# means nothing at all collects a run that simply stopped - its worktree stays
|
|
7
|
+
# registered forever. Measured on one machine: 20 runs reading `in_progress`,
|
|
8
|
+
# every one more than a day old, one of them 157 days, holding 28 worktrees
|
|
9
|
+
# across 7 repos and 22.8 GB.
|
|
10
|
+
#
|
|
11
|
+
# Three things keep this from being a foot-gun, and each is a rule rather than a
|
|
12
|
+
# heuristic:
|
|
13
|
+
#
|
|
14
|
+
# 1. A run WAITING FOR YOU is never reaped. An open PR, Phase 6 or 7, or
|
|
15
|
+
# `status: awaiting_input` means the work landed and the pipeline is
|
|
16
|
+
# holding for an answer by design. That is finished work, not residue.
|
|
17
|
+
# 2. A path that is not strictly inside `<repo>/.worktrees/` is never removed.
|
|
18
|
+
# This is not theoretical: four state files on that machine record
|
|
19
|
+
# `worktreePath` as the REPO ROOT, so a sweep that trusted the field would
|
|
20
|
+
# have deleted the checkout.
|
|
21
|
+
# 3. Uncommitted work is never destroyed. The branch is stashed to
|
|
22
|
+
# `autopilot/abandoned/<task-id>` and the directory is left in place, which
|
|
23
|
+
# costs disk and keeps the work. Losing a day of edits is worse than 750 MB.
|
|
24
|
+
#
|
|
25
|
+
# TWO PASSES, because the state files are not where the disk is. Measured across
|
|
26
|
+
# 28 worktrees holding 23 GB: only 5 of them (1.5 GB) belong to a run that still
|
|
27
|
+
# reads `in_progress`. 19 of them (15.6 GB) have NO state file at all, and 3 more
|
|
28
|
+
# (5.1 GB) belong to runs that finished and whose worktree outlived them. A
|
|
29
|
+
# state-driven sweep alone reaps 4% of the problem, so pass 2 walks the worktrees
|
|
30
|
+
# themselves and asks each one what its run is doing.
|
|
31
|
+
#
|
|
32
|
+
# Pass 2 keeps every rule above and adds the one that matters most: **a worktree
|
|
33
|
+
# with no run state on record is REPORTED, never removed.** `.worktrees/` is not
|
|
34
|
+
# exclusively ours - the measured machine holds `174`, `pr-4051`, `task-1` and
|
|
35
|
+
# `create-screen` there, all hand-made by the user, one of them in active use.
|
|
36
|
+
# Nothing distinguishes those from a pipeline worktree whose log was pruned, so
|
|
37
|
+
# the honest move is to show them with their size and let the user decide. They
|
|
38
|
+
# are the 15.6 GB, and pointing at them is worth more than a rule that guesses.
|
|
39
|
+
#
|
|
40
|
+
# So --yes removes exactly two things: a worktree whose run is recorded as
|
|
41
|
+
# finished, and one whose run is recorded as stopped past its age limit.
|
|
42
|
+
#
|
|
43
|
+
# Two ages, because two different things are being reaped. A run left at a Phase
|
|
44
|
+
# 0 question never started - Phase 0 is almost entirely questions - so there is
|
|
45
|
+
# no code to lose and a short window is safe. A run that died mid-development
|
|
46
|
+
# may hold real edits, so it gets a long one and the stash rule above.
|
|
47
|
+
#
|
|
48
|
+
# State is moved to `<run-dir>/artifacts/` before the worktree goes, so the log,
|
|
49
|
+
# the findings and the phase history survive the sweep. The same recovery
|
|
50
|
+
# contract Phase 6 uses when it removes a finished worktree.
|
|
51
|
+
#
|
|
52
|
+
# SAFE BY DEFAULT: dry-run. Removes nothing until you pass --yes.
|
|
53
|
+
#
|
|
54
|
+
# Usage:
|
|
55
|
+
# gc-abandoned.sh # dry-run, default ages
|
|
56
|
+
# gc-abandoned.sh --yes # apply
|
|
57
|
+
# gc-abandoned.sh --days=14 # mid-development runs idle > 14 days
|
|
58
|
+
# gc-abandoned.sh --phase0-days=3 # Phase 0 leftovers idle > 3 days
|
|
59
|
+
# gc-abandoned.sh --logs <dir> # another state root (tests)
|
|
60
|
+
# gc-abandoned.sh --repos <dir> # another checkout root (tests)
|
|
61
|
+
# gc-abandoned.sh --state-only # pass 1 only
|
|
62
|
+
#
|
|
63
|
+
# Exit: 0 on success (including "nothing to do"), 2 on usage error.
|
|
64
|
+
|
|
65
|
+
set -uo pipefail
|
|
66
|
+
|
|
67
|
+
LOGS="$HOME/.claude/logs/multi-agent"
|
|
68
|
+
REPOS="$HOME"
|
|
69
|
+
DELETE=0
|
|
70
|
+
DAYS=7
|
|
71
|
+
PHASE0_DAYS=1
|
|
72
|
+
STATE_ONLY=0
|
|
73
|
+
|
|
74
|
+
usage() {
|
|
75
|
+
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
while [ "$#" -gt 0 ]; do
|
|
79
|
+
case "$1" in
|
|
80
|
+
--yes | -y) DELETE=1; shift ;;
|
|
81
|
+
--days=*) DAYS="${1#*=}"; shift ;;
|
|
82
|
+
--phase0-days=*) PHASE0_DAYS="${1#*=}"; shift ;;
|
|
83
|
+
--logs) LOGS="${2:-}"; shift 2 ;;
|
|
84
|
+
--repos) REPOS="${2:-}"; shift 2 ;;
|
|
85
|
+
--state-only) STATE_ONLY=1; shift ;;
|
|
86
|
+
--abandoned) shift ;; # accepted so the command can forward its own flag
|
|
87
|
+
-h | --help) usage; exit 0 ;;
|
|
88
|
+
*) echo "gc-abandoned: unknown option $1" >&2; exit 2 ;;
|
|
89
|
+
esac
|
|
90
|
+
done
|
|
91
|
+
|
|
92
|
+
case "$DAYS$PHASE0_DAYS" in *[!0-9]*) echo "gc-abandoned: --days and --phase0-days take whole days" >&2; exit 2 ;; esac
|
|
93
|
+
|
|
94
|
+
command -v jq >/dev/null 2>&1 || { echo "gc-abandoned: jq is required"; exit 0; }
|
|
95
|
+
[ -d "$LOGS" ] || { echo "gc-abandoned: nothing to do (no state at $LOGS)"; exit 0; }
|
|
96
|
+
|
|
97
|
+
# GNU form FIRST. `stat -f` is a valid GNU flag (--file-system) that succeeds and
|
|
98
|
+
# prints a mount point, so BSD-first silently returns a number that is not a
|
|
99
|
+
# timestamp. ADR-0012 lists this among the constructs that look cross-platform
|
|
100
|
+
# and are load-bearing; it is the ordering that matters, not the platform.
|
|
101
|
+
_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"; }
|
|
102
|
+
|
|
103
|
+
NOW=$(date +%s)
|
|
104
|
+
removed=0
|
|
105
|
+
freed_kb=0
|
|
106
|
+
kept_dirty=0
|
|
107
|
+
skipped_waiting=0
|
|
108
|
+
skipped_unsafe=0
|
|
109
|
+
listed=0
|
|
110
|
+
# Paths pass 1 has already decided about. Pass 2 must not revisit them: pass 1
|
|
111
|
+
# stashes a dirty worktree and KEEPS it, which leaves the tree clean, and pass 2
|
|
112
|
+
# would then see a clean worktree whose run pass 1 just marked `failed` and
|
|
113
|
+
# remove the very thing that was preserved. The two passes are complementary,
|
|
114
|
+
# not sequential filters over the same set.
|
|
115
|
+
HANDLED=""
|
|
116
|
+
|
|
117
|
+
# Both layouts: phase-tracker.sh writes <task-id>/ and Phase 0 writes
|
|
118
|
+
# <project>/<task-id>/. Six task ids on the measured machine exist in both, so a
|
|
119
|
+
# sweep that walks one layout leaves the other copy claiming the run is live.
|
|
120
|
+
while IFS= read -r state; do
|
|
121
|
+
[ -f "$state" ] || continue
|
|
122
|
+
status=$(jq -r '.status // ""' "$state" 2>/dev/null)
|
|
123
|
+
# Only runs that CLAIM to be running. A file with no status is unknown, and
|
|
124
|
+
# unknown is not a licence to delete.
|
|
125
|
+
case "$status" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
|
|
126
|
+
|
|
127
|
+
task=$(jq -r '.taskId // .jiraId // ""' "$state" 2>/dev/null)
|
|
128
|
+
[ -n "$task" ] || continue
|
|
129
|
+
phase=$(jq -r '.currentPhase // "?"' "$state" 2>/dev/null)
|
|
130
|
+
# `.pr` is an object in the schema and a bare URL string in three state files
|
|
131
|
+
# on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
|
|
132
|
+
# reads as "no PR" - so a run whose PR is already open would be classed as dead
|
|
133
|
+
# and reaped. Read the shape, do not assume it.
|
|
134
|
+
pr=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$state" 2>/dev/null)
|
|
135
|
+
wt=$(jq -r '.worktreePath // ""' "$state" 2>/dev/null)
|
|
136
|
+
|
|
137
|
+
# Rule 1: waiting for you is not residue.
|
|
138
|
+
if [ "$status" = "awaiting_input" ] || [ -n "$pr" ] || [ "$phase" = "6" ] || [ "$phase" = "7" ]; then
|
|
139
|
+
skipped_waiting=$((skipped_waiting + 1))
|
|
140
|
+
continue
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
mtime=$(_mtime "$state")
|
|
144
|
+
age_days=$(((NOW - mtime) / 86400))
|
|
145
|
+
if [ "$phase" = "0" ]; then
|
|
146
|
+
limit="$PHASE0_DAYS"; kind="left at a Phase 0 question"
|
|
147
|
+
else
|
|
148
|
+
limit="$DAYS"; kind="stopped at Phase $phase"
|
|
149
|
+
fi
|
|
150
|
+
[ "$age_days" -gt "$limit" ] || continue
|
|
151
|
+
|
|
152
|
+
# Rule 2: the path must be strictly inside a .worktrees/ directory. A repo
|
|
153
|
+
# root recorded in worktreePath is the shape that would delete a checkout.
|
|
154
|
+
safe_wt=""
|
|
155
|
+
if [ -n "$wt" ] && [ -d "$wt" ] && [ ! -L "$wt" ]; then
|
|
156
|
+
real=$(cd "$wt" 2>/dev/null && pwd -P)
|
|
157
|
+
case "$real" in
|
|
158
|
+
*/.worktrees/*) safe_wt="$real" ;;
|
|
159
|
+
*) skipped_unsafe=$((skipped_unsafe + 1)) ;;
|
|
160
|
+
esac
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
size_kb=0
|
|
164
|
+
[ -n "$safe_wt" ] && size_kb=$(du -sk "$safe_wt" 2>/dev/null | cut -f1)
|
|
165
|
+
|
|
166
|
+
# Rule 3: uncommitted work is stashed, and the directory stays.
|
|
167
|
+
dirty=0
|
|
168
|
+
if [ -n "$safe_wt" ] && [ -n "$(git -C "$safe_wt" status --porcelain 2>/dev/null)" ]; then
|
|
169
|
+
dirty=1
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
listed=$((listed + 1))
|
|
173
|
+
[ -n "$safe_wt" ] && HANDLED="$HANDLED
|
|
174
|
+
$safe_wt"
|
|
175
|
+
human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
|
|
176
|
+
if [ "$DELETE" -eq 0 ]; then
|
|
177
|
+
if [ "$dirty" -eq 1 ]; then
|
|
178
|
+
printf ' would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
|
|
179
|
+
elif [ -n "$safe_wt" ]; then
|
|
180
|
+
printf ' would remove: %s - %s, idle %sd, %s\n' "$task" "$kind" "$age_days" "$human"
|
|
181
|
+
else
|
|
182
|
+
printf ' would mark abandoned (no reapable worktree): %s - %s, idle %sd\n' "$task" "$kind" "$age_days"
|
|
183
|
+
fi
|
|
184
|
+
continue
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
run_dir=$(dirname "$state")
|
|
188
|
+
mkdir -p "$run_dir/artifacts" 2>/dev/null
|
|
189
|
+
cp "$state" "$run_dir/artifacts/agent-state.json" 2>/dev/null
|
|
190
|
+
tmp="$state.tmp.$$"
|
|
191
|
+
if jq --arg r "abandoned by gc-abandoned after ${age_days}d idle" \
|
|
192
|
+
'.status = "failed" | .haltReason = $r' "$state" > "$tmp" 2>/dev/null; then
|
|
193
|
+
mv "$tmp" "$state"
|
|
194
|
+
else
|
|
195
|
+
rm -f "$tmp"
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
if [ "$dirty" -eq 1 ]; then
|
|
199
|
+
br="autopilot/abandoned/$task"
|
|
200
|
+
git -C "$safe_wt" stash push -u -m "$br" >/dev/null 2>&1 &&
|
|
201
|
+
printf '→ stashed uncommitted work, worktree kept: %s (%s)\n' "$task" "$br" ||
|
|
202
|
+
printf '→ could NOT stash, worktree kept untouched: %s\n' "$task"
|
|
203
|
+
kept_dirty=$((kept_dirty + 1))
|
|
204
|
+
continue
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [ -n "$safe_wt" ]; then
|
|
208
|
+
repo=${safe_wt%%/.worktrees/*}
|
|
209
|
+
git -C "$repo" worktree remove --force "$safe_wt" >/dev/null 2>&1 || rm -rf "$safe_wt"
|
|
210
|
+
git -C "$repo" worktree prune >/dev/null 2>&1
|
|
211
|
+
printf '→ removed: %s (%s, idle %sd)\n' "$task" "$human" "$age_days"
|
|
212
|
+
freed_kb=$((freed_kb + ${size_kb:-0}))
|
|
213
|
+
else
|
|
214
|
+
printf '→ marked abandoned, no reapable worktree: %s\n' "$task"
|
|
215
|
+
fi
|
|
216
|
+
removed=$((removed + 1))
|
|
217
|
+
done <<EOF
|
|
218
|
+
$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null)
|
|
219
|
+
EOF
|
|
220
|
+
|
|
221
|
+
# ---- pass 2: the worktrees themselves ---------------------------------------
|
|
222
|
+
#
|
|
223
|
+
# Driven from disk rather than from state, because 19 of the 28 worktrees
|
|
224
|
+
# measured have no state file to drive from. For each one the question is what
|
|
225
|
+
# its run is doing, answered by looking the state up by path and by task id.
|
|
226
|
+
stale_wt=0
|
|
227
|
+
unattributed=0
|
|
228
|
+
unattributed_kb=0
|
|
229
|
+
if [ "$STATE_ONLY" -eq 0 ] && [ -d "$REPOS" ]; then
|
|
230
|
+
# Every state file once, so the per-worktree lookup below is a grep and not a
|
|
231
|
+
# re-walk of the log tree for each of 28 directories.
|
|
232
|
+
index=$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null |
|
|
233
|
+
while IFS= read -r f; do
|
|
234
|
+
jq -r '[(.worktreePath // ""), (.taskId // .jiraId // ""), (.status // ""), (if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring), ((.currentPhase // "") | tostring)] | @tsv' "$f" 2>/dev/null
|
|
235
|
+
done)
|
|
236
|
+
|
|
237
|
+
for wtroot in "$REPOS"/*/.worktrees; do
|
|
238
|
+
[ -d "$wtroot" ] || continue
|
|
239
|
+
[ -L "$wtroot" ] && continue
|
|
240
|
+
repo=$(dirname "$wtroot")
|
|
241
|
+
for d in "$wtroot"/*; do
|
|
242
|
+
[ -d "$d" ] || continue
|
|
243
|
+
[ -L "$d" ] && continue
|
|
244
|
+
real=$(cd "$d" 2>/dev/null && pwd -P) || continue
|
|
245
|
+
# The namespace guard again, unconditionally: nothing outside a
|
|
246
|
+
# `.worktrees/` directory is a candidate, whatever any record claims.
|
|
247
|
+
case "$real" in */.worktrees/*) ;; *) continue ;; esac
|
|
248
|
+
printf '%s\n' "$HANDLED" | grep -qxF "$real" && continue
|
|
249
|
+
id=$(basename "$real")
|
|
250
|
+
|
|
251
|
+
row=$(printf '%s\n' "$index" | awk -F'\t' -v p="$real" -v i="$id" '$1==p || $2==i {print; exit}')
|
|
252
|
+
st=$(printf '%s' "$row" | cut -f3)
|
|
253
|
+
pr=$(printf '%s' "$row" | cut -f4)
|
|
254
|
+
ph=$(printf '%s' "$row" | cut -f5)
|
|
255
|
+
|
|
256
|
+
# Rule 1 again: landed work is not residue, whichever pass finds it. Order
|
|
257
|
+
# matters here - the phase-6/7 test is a proxy for "still waiting", and a
|
|
258
|
+
# run whose status already says it FINISHED is not waiting for anyone. A
|
|
259
|
+
# complete run sitting at phase 7 was read as waiting and never reaped,
|
|
260
|
+
# which is how three finished worktrees held 5.1 GB indefinitely.
|
|
261
|
+
case "$st" in
|
|
262
|
+
complete | completed | failed) ;;
|
|
263
|
+
awaiting_input)
|
|
264
|
+
skipped_waiting=$((skipped_waiting + 1)); continue ;;
|
|
265
|
+
in_progress | paused)
|
|
266
|
+
continue ;; # pass 1 owns these; do not report them twice
|
|
267
|
+
*)
|
|
268
|
+
if [ -n "$pr" ] || [ "$ph" = "6" ] || [ "$ph" = "7" ]; then
|
|
269
|
+
skipped_waiting=$((skipped_waiting + 1)); continue
|
|
270
|
+
fi ;;
|
|
271
|
+
esac
|
|
272
|
+
|
|
273
|
+
attributed=1
|
|
274
|
+
case "$st" in
|
|
275
|
+
complete | completed | failed)
|
|
276
|
+
why="run finished, worktree outlived it" ;;
|
|
277
|
+
*)
|
|
278
|
+
why="no run state on record - yours to judge, never removed here"
|
|
279
|
+
attributed=0 ;;
|
|
280
|
+
esac
|
|
281
|
+
|
|
282
|
+
# Age from the newest file in the tree, not from the directory stamp: a
|
|
283
|
+
# directory mtime only tracks its immediate entries, so an actively edited
|
|
284
|
+
# worktree can look untouched for weeks.
|
|
285
|
+
newest=$(find "$real" -type f -not -path '*/.git/*' -print0 2>/dev/null |
|
|
286
|
+
xargs -0 ls -t 2>/dev/null | head -1)
|
|
287
|
+
mt=$(_mtime "${newest:-$real}")
|
|
288
|
+
age_days=$(((NOW - mt) / 86400))
|
|
289
|
+
[ "$age_days" -gt "$DAYS" ] || continue
|
|
290
|
+
|
|
291
|
+
size_kb=$(du -sk "$real" 2>/dev/null | cut -f1)
|
|
292
|
+
human=$(printf '%s' "${size_kb:-0}" | awk '{printf "%.1fG", $1/1048576}')
|
|
293
|
+
dirty=0
|
|
294
|
+
[ -n "$(git -C "$real" status --porcelain 2>/dev/null)" ] && dirty=1
|
|
295
|
+
|
|
296
|
+
if [ "$attributed" -eq 0 ]; then
|
|
297
|
+
unattributed=$((unattributed + 1))
|
|
298
|
+
unattributed_kb=$((unattributed_kb + ${size_kb:-0}))
|
|
299
|
+
printf ' %s %s - %s, idle %sd, %s\n' \
|
|
300
|
+
"$([ "$DELETE" -eq 0 ] && echo "unattributed:" || echo "left alone:")" \
|
|
301
|
+
"$id" "$why" "$age_days" "$human"
|
|
302
|
+
continue
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
stale_wt=$((stale_wt + 1))
|
|
306
|
+
if [ "$DELETE" -eq 0 ]; then
|
|
307
|
+
if [ "$dirty" -eq 1 ]; then
|
|
308
|
+
printf ' would stash (uncommitted work): %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
|
|
309
|
+
else
|
|
310
|
+
printf ' would remove worktree: %s - %s, idle %sd, %s\n' "$id" "$why" "$age_days" "$human"
|
|
311
|
+
fi
|
|
312
|
+
continue
|
|
313
|
+
fi
|
|
314
|
+
|
|
315
|
+
if [ "$dirty" -eq 1 ]; then
|
|
316
|
+
git -C "$real" stash push -u -m "autopilot/abandoned/$id" >/dev/null 2>&1 &&
|
|
317
|
+
printf '→ stashed uncommitted work, worktree kept: %s\n' "$id" ||
|
|
318
|
+
printf '→ could NOT stash, worktree kept untouched: %s\n' "$id"
|
|
319
|
+
kept_dirty=$((kept_dirty + 1))
|
|
320
|
+
continue
|
|
321
|
+
fi
|
|
322
|
+
git -C "$repo" worktree remove --force "$real" >/dev/null 2>&1 || rm -rf "$real"
|
|
323
|
+
git -C "$repo" worktree prune >/dev/null 2>&1
|
|
324
|
+
printf '→ removed worktree: %s (%s, %s, idle %sd)\n' "$id" "$why" "$human" "$age_days"
|
|
325
|
+
freed_kb=$((freed_kb + ${size_kb:-0}))
|
|
326
|
+
removed=$((removed + 1))
|
|
327
|
+
done
|
|
328
|
+
done
|
|
329
|
+
fi
|
|
330
|
+
listed=$((listed + stale_wt))
|
|
331
|
+
|
|
332
|
+
note=""
|
|
333
|
+
[ "$skipped_waiting" -gt 0 ] && note="$note, $skipped_waiting waiting on you (kept)"
|
|
334
|
+
[ "$skipped_unsafe" -gt 0 ] && note="$note, $skipped_unsafe path(s) outside .worktrees/ (kept)"
|
|
335
|
+
[ "$kept_dirty" -gt 0 ] && note="$note, $kept_dirty stashed and kept"
|
|
336
|
+
if [ "$unattributed" -gt 0 ]; then
|
|
337
|
+
uh=$(printf '%s' "$unattributed_kb" | awk '{printf "%.1fG", $1/1048576}')
|
|
338
|
+
note="$note, $unattributed worktree(s) holding $uh with no run state (listed, untouched)"
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
if [ "$DELETE" -eq 0 ]; then
|
|
342
|
+
if [ "$listed" -eq 0 ]; then
|
|
343
|
+
printf 'gc-abandoned: nothing to do%s\n' "${note:+ (}${note#, }${note:+)}"
|
|
344
|
+
else
|
|
345
|
+
printf '══ gc-abandoned (dry-run): %d run(s) would be reaped%s ══\n' "$listed" "$note"
|
|
346
|
+
fi
|
|
347
|
+
exit 0
|
|
348
|
+
fi
|
|
349
|
+
|
|
350
|
+
freed_h=$(printf '%s' "$freed_kb" | awk '{printf "%.1fG", $1/1048576}')
|
|
351
|
+
printf '══ gc-abandoned: reaped %d run(s), freed ~%s%s ══\n' "$removed" "$freed_h" "$note"
|
|
352
|
+
exit 0
|
|
@@ -189,7 +189,7 @@ def _macos_find(args: list[str]) -> Optional[str]:
|
|
|
189
189
|
|
|
190
190
|
def get(label: str, account: Optional[str] = None) -> Optional[str]:
|
|
191
191
|
"""Lookup by label first, then by service name. Two coexisting conventions:
|
|
192
|
-
- User's manually-added tokens (e.g.
|
|
192
|
+
- User's manually-added tokens (e.g. ${USER}_Vercel_Access_Token) use -l (label).
|
|
193
193
|
- credential-store.sh-managed items use -s (service).
|
|
194
194
|
The helper finds either."""
|
|
195
195
|
pf = _ensure_backend()
|
|
@@ -177,12 +177,12 @@ function invokedCalls(state) {
|
|
|
177
177
|
return Array.from(names).slice(0, 30);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// The package declares `os: ["darwin"]` and every entry point refuses a
|
|
181
|
+
// non-darwin host, so the branches this used to carry were unreachable. The
|
|
182
|
+
// function and the field stay: the panel groups by `os` and a row with the key
|
|
183
|
+
// missing reads as "unknown host" rather than "macOS".
|
|
180
184
|
function detectOs() {
|
|
181
|
-
|
|
182
|
-
if (p === "darwin") return "macos";
|
|
183
|
-
if (p === "win32") return "windows";
|
|
184
|
-
if (p === "linux") return "linux";
|
|
185
|
-
return p;
|
|
185
|
+
return platform() === "darwin" ? "macos" : platform();
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
function detectCli() {
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# gate-linux.sh - run the ci-lite job on a real Ubuntu image, locally.
|
|
3
|
-
#
|
|
4
|
-
# The pre-push gate (pre-push-check.sh) is the primary one, but it runs on the
|
|
5
|
-
# maintainer's macOS workstation against an installed node_modules that has
|
|
6
|
-
# accumulated over months. Two things it cannot answer: does this tree work on
|
|
7
|
-
# Linux, and does `npm ci` from the lockfile into an empty tree still resolve.
|
|
8
|
-
# ci-lite.yml answers both, and has not executed since 2026-07-25.
|
|
9
|
-
#
|
|
10
|
-
# `act` runs that workflow against the same image GitHub would use, so the
|
|
11
|
-
# answer comes from the workflow definition rather than from a second script
|
|
12
|
-
# that would drift away from it.
|
|
13
|
-
#
|
|
14
|
-
# NOT part of `npm run gate` on purpose: act and Docker are not installed
|
|
15
|
-
# everywhere, and a required gate that cannot run is the failure ADR-0011
|
|
16
|
-
# exists to avoid. Run it before a release, or after touching install/,
|
|
17
|
-
# lib/credential-store.sh, or anything path-shaped.
|
|
18
|
-
#
|
|
19
|
-
# Needs network inside the container: the scorecard's dependency metrics ask
|
|
20
|
-
# the registry (advisories and signatures) and report an unreachable registry
|
|
21
|
-
# as a failure rather than a skip, on the grounds that a supply-chain check
|
|
22
|
-
# which goes green without reaching the registry is worse than no check.
|
|
23
|
-
#
|
|
24
|
-
# Exit codes: 0 = the job passed, 1 = the job failed, 2 = tooling missing
|
|
25
|
-
|
|
26
|
-
set -uo pipefail
|
|
27
|
-
|
|
28
|
-
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
29
|
-
cd "$REPO_ROOT" || { echo "FAIL: can't cd to repo root" >&2; exit 2; }
|
|
30
|
-
|
|
31
|
-
if ! command -v act >/dev/null 2>&1; then
|
|
32
|
-
cat >&2 <<'MSG'
|
|
33
|
-
✗ act is not installed - this gate needs it to run the Ubuntu job locally.
|
|
34
|
-
|
|
35
|
-
brew install act # macOS
|
|
36
|
-
# and a running Docker daemon (Docker Desktop, colima, orbstack)
|
|
37
|
-
|
|
38
|
-
Why this is not bundled: it pulls a multi-gigabyte runner image. The rest of
|
|
39
|
-
the gate chain (npm run gate) has no such dependency and stays the default.
|
|
40
|
-
MSG
|
|
41
|
-
exit 2
|
|
42
|
-
fi
|
|
43
|
-
|
|
44
|
-
if ! docker info >/dev/null 2>&1; then
|
|
45
|
-
echo "✗ act is installed but no Docker daemon is reachable. Start Docker and retry." >&2
|
|
46
|
-
exit 2
|
|
47
|
-
fi
|
|
48
|
-
|
|
49
|
-
echo "→ Running ci-lite on ubuntu-22.04 via act (this pulls an image on first run)"
|
|
50
|
-
echo ""
|
|
51
|
-
|
|
52
|
-
if act workflow_dispatch \
|
|
53
|
-
-W .github/workflows/ci-lite.yml \
|
|
54
|
-
-P ubuntu-latest=catthehacker/ubuntu:act-22.04; then
|
|
55
|
-
echo ""
|
|
56
|
-
echo "✓ ci-lite passed on Linux"
|
|
57
|
-
exit 0
|
|
58
|
-
fi
|
|
59
|
-
|
|
60
|
-
echo "" >&2
|
|
61
|
-
echo "✗ ci-lite failed on Linux. A pass on macOS does not cover this." >&2
|
|
62
|
-
exit 1
|