@indigoai-us/hq-cli 5.315.0 → 5.316.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 +9 -0
- package/assets/scaffold/core/scripts/detect-stale-review-base.sh +199 -0
- package/assets/scaffold/core/scripts/repo-run-registry.sh +408 -0
- package/assets/scaffold/core/scripts/share-suggestion-state.sh +541 -0
- package/dist/command-catalog.generated.d.ts +45 -0
- package/dist/command-catalog.generated.js +56 -0
- package/dist/commands/bot-jobs.d.ts +5 -1
- package/dist/commands/bot-jobs.js +24 -4
- package/dist/commands/core-detect-stale-review-base-native.d.ts +23 -0
- package/dist/commands/core-detect-stale-review-base-native.js +178 -0
- package/dist/commands/core-hook-state-helpers-native.d.ts +11 -0
- package/dist/commands/core-hook-state-helpers-native.js +74 -0
- package/dist/commands/core-repo-run-registry-native.d.ts +19 -0
- package/dist/commands/core-repo-run-registry-native.js +357 -0
- package/dist/commands/core-share-suggestion-state-native.d.ts +18 -0
- package/dist/commands/core-share-suggestion-state-native.js +445 -0
- package/dist/commands/core.js +5 -0
- package/dist/commands/lanes.js +35 -5
- package/dist/fast-core.d.ts +1 -0
- package/dist/fast-core.js +10 -6
- package/dist/fast-scaffold.js +17 -0
- package/dist/lib/bot/job-quiet-flag.d.ts +4 -0
- package/dist/lib/bot/job-quiet-flag.js +14 -0
- package/dist/lib/bot/job-run.d.ts +5 -0
- package/dist/lib/bot/job-run.js +23 -3
- package/dist/lib/bot/run.d.ts +2 -0
- package/dist/lib/bot/run.js +15 -3
- package/dist/lib/core-utils/common.d.ts +0 -1
- package/dist/lib/core-utils/common.js +0 -9
- package/dist/lib/lanes/autopilot.d.ts +5 -1
- package/dist/lib/lanes/autopilot.js +72 -3
- package/dist/lib/lanes/disk-guard.d.ts +3 -4
- package/dist/lib/lanes/disk-guard.js +2 -2
- package/dist/lib/lanes/flag-keys.d.ts +3 -0
- package/dist/lib/lanes/flag-keys.js +3 -0
- package/dist/lib/lanes/hooks-install.js +78 -2
- package/dist/lib/lanes/keep-alive-audit.d.ts +6 -5
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -0
- package/dist/lib/lanes/outpost-dispatch.js +22 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.316.0] — 2026-10-02
|
|
6
|
+
- Add a default-off autopilot notice for registered lead headlines that remain stale for more than 55 minutes, using `headline_at` or the timestamp prefix in `now`.
|
|
7
|
+
- Add a flag-gated per-job option to complete successful scheduled bot runs without an owner DM; failures still report normally.
|
|
8
|
+
- Port `share-suggestion-state`, `repo-run-registry`, and `detect-stale-review-base` to native TypeScript on the fast route behind the default-off `core-native-hook-helpers` flag.
|
|
9
|
+
- Detect the installed hq-cli drain entry in provider settings so lane messages do not warn when the running worker can drain its inbox.
|
|
10
|
+
- Resolve hostless Outpost lane lookups by ULID without transporting the full lane list, including compatibility with older Outpost CLIs.
|
|
11
|
+
- Refuse to publish a stable or rc release tag unless its commit is on main, its version is higher than npm latest, and it contains the commit npm latest was built from. This stops a tag on an old commit from putting older code on npm as latest.
|
|
12
|
+
- Remove an unused file-exists helper and stop exporting four disk-guard internals. No behaviour change.
|
|
13
|
+
|
|
5
14
|
## [5.315.0] — 2026-10-02
|
|
6
15
|
- The CLI loads the Sentry error-reporting library only when it first needs it, so commands that report nothing start with fewer imports. Reporting, scrubbing and flushing work as before.
|
|
7
16
|
- Keep first-sync guidance enabled without its rollout gate and remove the obsolete plan-limit fallback flag-off test.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# detect-stale-review-base.sh — find remote-tracking refs in the HQ root that
|
|
3
|
+
# have fallen so far behind local HEAD that a diff/review tool will pick one as
|
|
4
|
+
# its comparison base and try to review the entire tree.
|
|
5
|
+
#
|
|
6
|
+
# Background: HQ autosaves one commit per edited file (.claude/hooks/hq-autocommit.sh)
|
|
7
|
+
# and is never pushed to a remote (policy hq-root-never-push-remote). An HQ root
|
|
8
|
+
# that is itself a git repo with a remote attached therefore accumulates local
|
|
9
|
+
# commits the remote-tracking ref never receives. The gap only grows.
|
|
10
|
+
#
|
|
11
|
+
# Review tools that auto-select a base branch resolve it from refs/remotes/*.
|
|
12
|
+
# Once the gap is large enough that base is effectively "the empty tree", so the
|
|
13
|
+
# review payload becomes the whole repository instead of the working delta. The
|
|
14
|
+
# observed failure (2026-07-28) was a Codex desktop git worker taking SIGTRAP on
|
|
15
|
+
# a 108,434-path / 29.2 MB payload built against a remote-tracking ref 22,881
|
|
16
|
+
# commits behind local main, retried until the app reached 5.5 GB RSS.
|
|
17
|
+
#
|
|
18
|
+
# Codex exposes no configuration for the review base — `review_model` only picks
|
|
19
|
+
# the reviewing model, and `auto_review` governs the approval-reviewer subagent,
|
|
20
|
+
# not code review. `codex review --base` is per-invocation and does not reach the
|
|
21
|
+
# desktop app's automatic review. Removing the stale ref is therefore the only
|
|
22
|
+
# lever available on the HQ side: with nothing stale to resolve, there is no
|
|
23
|
+
# whole-tree base to pick.
|
|
24
|
+
#
|
|
25
|
+
# Modes:
|
|
26
|
+
# (default) human-readable report; exit 0.
|
|
27
|
+
# --json machine-readable JSON array; exit 0.
|
|
28
|
+
# --check exit 0 if clean, 1 if any stale base exists (advisory gate).
|
|
29
|
+
# --fix delete the offending remote-tracking refs; print each action.
|
|
30
|
+
#
|
|
31
|
+
# --fix deletes ONLY refs under refs/remotes/. It never touches refs/heads/,
|
|
32
|
+
# never removes a configured remote, never fetches, and never pushes. The
|
|
33
|
+
# deletion is recoverable: `git fetch <remote>` recreates the ref (and, on an HQ
|
|
34
|
+
# root, recreates this condition — HQ roots are not meant to be fetched).
|
|
35
|
+
#
|
|
36
|
+
# Thresholds (override with flags or env):
|
|
37
|
+
# --max-paths N changed paths tolerated vs. a base (default 5000)
|
|
38
|
+
# --max-commits N commits ahead tolerated vs. a base (default 1000)
|
|
39
|
+
# A ref is stale when EITHER threshold is exceeded. Path count is the primary
|
|
40
|
+
# signal: it is what determines the review payload size.
|
|
41
|
+
|
|
42
|
+
set -euo pipefail
|
|
43
|
+
|
|
44
|
+
MODE="report"
|
|
45
|
+
MAX_PATHS="${HQ_REVIEW_BASE_MAX_PATHS:-5000}"
|
|
46
|
+
MAX_COMMITS="${HQ_REVIEW_BASE_MAX_COMMITS:-1000}"
|
|
47
|
+
ROOT_OVERRIDE=""
|
|
48
|
+
|
|
49
|
+
while [ $# -gt 0 ]; do
|
|
50
|
+
case "$1" in
|
|
51
|
+
--json) MODE="json"; shift ;;
|
|
52
|
+
--check) MODE="check"; shift ;;
|
|
53
|
+
--fix) MODE="fix"; shift ;;
|
|
54
|
+
--max-paths) MAX_PATHS="${2:?--max-paths needs a value}"; shift 2 ;;
|
|
55
|
+
--max-commits) MAX_COMMITS="${2:?--max-commits needs a value}"; shift 2 ;;
|
|
56
|
+
--root) ROOT_OVERRIDE="${2:?--root needs a value}"; shift 2 ;;
|
|
57
|
+
-h|--help) sed -n '2,41p' "$0"; exit 0 ;;
|
|
58
|
+
*)
|
|
59
|
+
printf 'detect-stale-review-base: unknown argument: %s\n' "$1" >&2
|
|
60
|
+
printf 'usage: %s [--json|--check|--fix] [--max-paths N] [--max-commits N] [--root PATH]\n' "$0" >&2
|
|
61
|
+
exit 2 ;;
|
|
62
|
+
esac
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
case "$MAX_PATHS" in ''|*[!0-9]*)
|
|
66
|
+
echo "detect-stale-review-base: --max-paths must be a non-negative integer" >&2; exit 2 ;;
|
|
67
|
+
esac
|
|
68
|
+
case "$MAX_COMMITS" in ''|*[!0-9]*)
|
|
69
|
+
echo "detect-stale-review-base: --max-commits must be a non-negative integer" >&2; exit 2 ;;
|
|
70
|
+
esac
|
|
71
|
+
|
|
72
|
+
# Resolve HQ root: explicit flag wins, then env, then the tree this script ships in.
|
|
73
|
+
# Physical paths (pwd -P) so the nesting guard below cannot be dodged through a
|
|
74
|
+
# symlink to a clone.
|
|
75
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
76
|
+
HQ_ROOT="${ROOT_OVERRIDE:-${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}}}"
|
|
77
|
+
|
|
78
|
+
if [ ! -d "$HQ_ROOT" ]; then
|
|
79
|
+
echo "detect-stale-review-base: no such directory: $HQ_ROOT" >&2
|
|
80
|
+
exit 2
|
|
81
|
+
fi
|
|
82
|
+
HQ_ROOT="$(cd "$HQ_ROOT" && pwd -P)"
|
|
83
|
+
|
|
84
|
+
emit_clean() {
|
|
85
|
+
case "$MODE" in
|
|
86
|
+
json) echo "[]" ;;
|
|
87
|
+
check) : ;;
|
|
88
|
+
fix) printf 'detect-stale-review-base: nothing to fix (%s)\n' "$1" ;;
|
|
89
|
+
*) printf 'detect-stale-review-base: clean (%s)\n' "$1" ;;
|
|
90
|
+
esac
|
|
91
|
+
exit 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Only ever operate on an HQ root. This guard is what keeps the tool away from
|
|
95
|
+
# repos/ working clones, where a remote-tracking ref far behind local HEAD is
|
|
96
|
+
# ordinary and its refs must never be deleted.
|
|
97
|
+
if [ ! -f "$HQ_ROOT/core/core.yaml" ]; then
|
|
98
|
+
echo "detect-stale-review-base: $HQ_ROOT is not an HQ root (no core/core.yaml)" >&2
|
|
99
|
+
exit 2
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# A repos/ clone of hq-core is itself HQ-root-shaped — core/core.yaml ships in
|
|
103
|
+
# the repo, so the check above cannot tell it from a real install. What does
|
|
104
|
+
# distinguish a working clone is position: clones and worktree checkouts live
|
|
105
|
+
# NESTED inside another HQ root (repos/, workspace/worktrees/), while a real HQ
|
|
106
|
+
# root never does. Refuse any candidate with an HQ-root-shaped ancestor rather
|
|
107
|
+
# than trusting the caller.
|
|
108
|
+
ANCESTOR="$(dirname "$HQ_ROOT")"
|
|
109
|
+
while [ -n "$ANCESTOR" ] && [ "$ANCESTOR" != "/" ] && [ "$ANCESTOR" != "." ]; do
|
|
110
|
+
if [ -f "$ANCESTOR/core/core.yaml" ]; then
|
|
111
|
+
echo "detect-stale-review-base: $HQ_ROOT is nested inside the HQ root $ANCESTOR — refusing. Working clones and worktree checkouts keep their remote-tracking refs; this tool only repairs a top-level HQ install." >&2
|
|
112
|
+
exit 2
|
|
113
|
+
fi
|
|
114
|
+
ANCESTOR="$(dirname "$ANCESTOR")"
|
|
115
|
+
done
|
|
116
|
+
|
|
117
|
+
if [ "$(git -C "$HQ_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" != "$HQ_ROOT" ]; then
|
|
118
|
+
emit_clean "HQ root is not a git repository — no review base to go stale"
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
if ! git -C "$HQ_ROOT" rev-parse --verify -q HEAD >/dev/null 2>&1; then
|
|
122
|
+
emit_clean "HQ root has no commits yet"
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
REFS="$(git -C "$HQ_ROOT" for-each-ref --format='%(refname)' refs/remotes 2>/dev/null || true)"
|
|
126
|
+
if [ -z "$REFS" ]; then
|
|
127
|
+
emit_clean "no remote-tracking refs in the HQ root"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
STALE_REFS=""
|
|
131
|
+
STALE_JSON=""
|
|
132
|
+
STALE_COUNT=0
|
|
133
|
+
|
|
134
|
+
while IFS= read -r ref; do
|
|
135
|
+
[ -n "$ref" ] || continue
|
|
136
|
+
# Symbolic refs (refs/remotes/<remote>/HEAD) resolve to a ref already covered.
|
|
137
|
+
if git -C "$HQ_ROOT" symbolic-ref -q "$ref" >/dev/null 2>&1; then
|
|
138
|
+
continue
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
commits="$(git -C "$HQ_ROOT" rev-list --count "$ref..HEAD" 2>/dev/null || true)"
|
|
142
|
+
[ -n "$commits" ] || continue
|
|
143
|
+
|
|
144
|
+
# Three-dot: paths changed since the merge base — what a review of
|
|
145
|
+
# "HEAD against this base" would actually carry. This is the expensive probe
|
|
146
|
+
# on exactly the repos this tool exists for (it IS the whole-tree diff), so
|
|
147
|
+
# skip it when the commit gap alone already proves staleness and the mode
|
|
148
|
+
# does not display counts. --json always measures both.
|
|
149
|
+
paths=""
|
|
150
|
+
if [ "$commits" -le "$MAX_COMMITS" ] || [ "$MODE" = "json" ]; then
|
|
151
|
+
paths="$(git -C "$HQ_ROOT" diff --name-only "$ref...HEAD" 2>/dev/null | wc -l | tr -d ' ')"
|
|
152
|
+
[ -n "$paths" ] || paths=0
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
if [ "$commits" -le "$MAX_COMMITS" ] && [ "$paths" -le "$MAX_PATHS" ]; then
|
|
156
|
+
continue
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
STALE_COUNT=$((STALE_COUNT + 1))
|
|
160
|
+
STALE_REFS="${STALE_REFS}${ref}"$'\n'
|
|
161
|
+
if [ "$MODE" = "json" ]; then
|
|
162
|
+
[ -z "$STALE_JSON" ] || STALE_JSON="${STALE_JSON},"
|
|
163
|
+
STALE_JSON="${STALE_JSON}{\"ref\":\"${ref}\",\"commits_ahead\":${commits},\"changed_paths\":${paths}}"
|
|
164
|
+
fi
|
|
165
|
+
done <<EOF
|
|
166
|
+
$REFS
|
|
167
|
+
EOF
|
|
168
|
+
|
|
169
|
+
if [ "$STALE_COUNT" -eq 0 ]; then
|
|
170
|
+
emit_clean "every remote-tracking ref is within ${MAX_PATHS} paths / ${MAX_COMMITS} commits of HEAD"
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
case "$MODE" in
|
|
174
|
+
json)
|
|
175
|
+
printf '[%s]\n' "$STALE_JSON"
|
|
176
|
+
;;
|
|
177
|
+
check)
|
|
178
|
+
printf 'detect-stale-review-base: %s stale review base(s) in %s\n' "$STALE_COUNT" "$HQ_ROOT" >&2
|
|
179
|
+
exit 1
|
|
180
|
+
;;
|
|
181
|
+
fix)
|
|
182
|
+
printf '%s' "$STALE_REFS" | while IFS= read -r ref; do
|
|
183
|
+
[ -n "$ref" ] || continue
|
|
184
|
+
git -C "$HQ_ROOT" update-ref -d "$ref"
|
|
185
|
+
printf 'detect-stale-review-base: deleted %s\n' "$ref"
|
|
186
|
+
done
|
|
187
|
+
printf 'detect-stale-review-base: removed %s stale review base(s). A later `git fetch` in the HQ root recreates them — HQ roots are not meant to be fetched (policy hq-root-never-push-remote).\n' "$STALE_COUNT"
|
|
188
|
+
;;
|
|
189
|
+
*)
|
|
190
|
+
printf 'detect-stale-review-base: %s stale review base(s) in %s\n\n' "$STALE_COUNT" "$HQ_ROOT"
|
|
191
|
+
printf '%s' "$STALE_REFS" | while IFS= read -r ref; do
|
|
192
|
+
[ -n "$ref" ] || continue
|
|
193
|
+
c="$(git -C "$HQ_ROOT" rev-list --count "$ref..HEAD" 2>/dev/null || echo '?')"
|
|
194
|
+
p="$(git -C "$HQ_ROOT" diff --name-only "$ref...HEAD" 2>/dev/null | wc -l | tr -d ' ')"
|
|
195
|
+
printf ' %s — HEAD is %s commits ahead, %s changed paths\n' "$ref" "$c" "$p"
|
|
196
|
+
done
|
|
197
|
+
printf '\nA review tool that auto-selects a base will pick one of these and build a\nwhole-tree payload from it. Remove them with:\n\n bash core/scripts/detect-stale-review-base.sh --fix\n'
|
|
198
|
+
;;
|
|
199
|
+
esac
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# repo-run-registry.sh — CLI for cross-session repo-level run coordination
|
|
3
|
+
#
|
|
4
|
+
# Manages workspace/orchestrator/active-runs.json: a global registry of
|
|
5
|
+
# long-running Claude sessions that currently hold a repo (scope=repo) or
|
|
6
|
+
# a specific worktree (scope=worktree:<path>). Other sessions consult this
|
|
7
|
+
# registry at SessionStart and before Edit/Write/dangerous Bash to avoid
|
|
8
|
+
# concurrent edit conflicts.
|
|
9
|
+
#
|
|
10
|
+
# See plan: ~/.claude/plans/structured-stargazing-candy.md
|
|
11
|
+
# Policy: core/policies/repo-run-coordination.md
|
|
12
|
+
#
|
|
13
|
+
# Subcommands:
|
|
14
|
+
# register --run-id X --pid N --session-id S --command C --project P --repo R --scope SC [--host H]
|
|
15
|
+
# deregister --run-id X
|
|
16
|
+
# heartbeat --run-id X
|
|
17
|
+
# list
|
|
18
|
+
# check --target PATH [--pid N] [--session-id S]
|
|
19
|
+
# clean-stale
|
|
20
|
+
# owner-of --path PATH
|
|
21
|
+
#
|
|
22
|
+
# Exit codes:
|
|
23
|
+
# 0 = ok / not blocked
|
|
24
|
+
# 2 = blocked by foreign owner (check only)
|
|
25
|
+
# 1 = usage / internal error
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
30
|
+
# shellcheck source=core/scripts/lib/portable.sh
|
|
31
|
+
. "$HQ_ROOT/core/scripts/lib/portable.sh"
|
|
32
|
+
REG_DIR="$HQ_ROOT/workspace/orchestrator"
|
|
33
|
+
REG_FILE="$REG_DIR/active-runs.json"
|
|
34
|
+
LOCK_DIR="$REG_FILE.lock"
|
|
35
|
+
# Resolve the orchestrator settings file. personal/settings is read DIRECTLY now
|
|
36
|
+
# (the reindex symlink mirror into core/settings is retired). orchestrator.yaml
|
|
37
|
+
# ships as a core default; unlike the list-shaped overlays (policies/workers/
|
|
38
|
+
# knowledge, where core + personal BOTH surface), this is a singleton config
|
|
39
|
+
# file — so a personal copy, when present, overrides the shipped default.
|
|
40
|
+
ORCH_YAML="$HQ_ROOT/core/settings/orchestrator.yaml"
|
|
41
|
+
if [[ -f "$HQ_ROOT/personal/settings/orchestrator.yaml" ]]; then
|
|
42
|
+
ORCH_YAML="$HQ_ROOT/personal/settings/orchestrator.yaml"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# ---------- helpers ----------
|
|
46
|
+
|
|
47
|
+
_log() { echo "[repo-run-registry] $*" >&2; }
|
|
48
|
+
_die() { echo "ERROR: $*" >&2; exit 1; }
|
|
49
|
+
|
|
50
|
+
_iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
|
|
51
|
+
|
|
52
|
+
# ISO8601 (UTC, trailing Z) → epoch seconds. BSD date first, then GNU date.
|
|
53
|
+
# A BSD-only parse returned 0 on Linux, so _prune_stale treated every live
|
|
54
|
+
# run as stale and deleted it before any check could block on it.
|
|
55
|
+
_iso_to_epoch() {
|
|
56
|
+
local iso="$1"
|
|
57
|
+
date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$iso" +%s 2>/dev/null \
|
|
58
|
+
|| date -u -d "$iso" +%s 2>/dev/null \
|
|
59
|
+
|| echo 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_hostname() { hostname -s 2>/dev/null || echo "unknown"; }
|
|
63
|
+
|
|
64
|
+
_abs_path() {
|
|
65
|
+
local p="$1"
|
|
66
|
+
if [[ "$p" = /* ]]; then
|
|
67
|
+
echo "$p"
|
|
68
|
+
else
|
|
69
|
+
(cd "$(dirname "$p")" 2>/dev/null && echo "$(pwd)/$(basename "$p")") || echo "$p"
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_ensure_reg() {
|
|
74
|
+
mkdir -p "$REG_DIR"
|
|
75
|
+
if [[ ! -f "$REG_FILE" ]]; then
|
|
76
|
+
echo '{"version":1,"runs":[]}' > "$REG_FILE"
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Atomic mutex via mkdir — single-machine only
|
|
81
|
+
_lock() { _try_lock || _die "registry lock timeout"; }
|
|
82
|
+
|
|
83
|
+
# Returns 1 instead of exiting when the lock stays busy (~5s).
|
|
84
|
+
_try_lock() {
|
|
85
|
+
local tries=0
|
|
86
|
+
local max=50
|
|
87
|
+
# register takes the lock before _ensure_reg; without its parent the mkdir
|
|
88
|
+
# below can never succeed and the first register on a fresh install timed out.
|
|
89
|
+
mkdir -p "$REG_DIR" 2>/dev/null || true
|
|
90
|
+
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
|
91
|
+
tries=$((tries + 1))
|
|
92
|
+
if [[ $tries -ge $max ]]; then
|
|
93
|
+
# stale lock fallback: remove if older than 60s
|
|
94
|
+
if [[ -d "$LOCK_DIR" ]]; then
|
|
95
|
+
local age mtime
|
|
96
|
+
mtime="$(portable_stat_mtime "$LOCK_DIR" 2>/dev/null || echo 0)"
|
|
97
|
+
age=$(( $(date +%s) - mtime ))
|
|
98
|
+
if [[ $age -gt 60 ]]; then
|
|
99
|
+
rm -rf "$LOCK_DIR" 2>/dev/null || true
|
|
100
|
+
continue
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
return 1
|
|
104
|
+
fi
|
|
105
|
+
sleep 0.1
|
|
106
|
+
done
|
|
107
|
+
}
|
|
108
|
+
_unlock() { rm -rf "$LOCK_DIR" 2>/dev/null || true; }
|
|
109
|
+
|
|
110
|
+
# list, owner-of and check prune too. Hold the lock so a reader cannot write an
|
|
111
|
+
# older snapshot over a concurrent register or heartbeat. If the lock stays
|
|
112
|
+
# busy, skip the prune and answer from the file as it is.
|
|
113
|
+
_prune_stale_locked() {
|
|
114
|
+
_try_lock || return 0
|
|
115
|
+
trap '_unlock' EXIT
|
|
116
|
+
_prune_stale >/dev/null 2>&1 || true
|
|
117
|
+
_unlock
|
|
118
|
+
trap - EXIT
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_is_pid_alive() {
|
|
122
|
+
local pid="$1"
|
|
123
|
+
[[ -z "$pid" || "$pid" == "null" ]] && return 1
|
|
124
|
+
kill -0 "$pid" 2>/dev/null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_stale_minutes() {
|
|
128
|
+
if [[ -f "$ORCH_YAML" ]] && command -v yq >/dev/null 2>&1; then
|
|
129
|
+
local v
|
|
130
|
+
v=$(yq eval '.repo_coordination.stale_heartbeat_minutes // 15' "$ORCH_YAML" 2>/dev/null)
|
|
131
|
+
[[ -n "$v" && "$v" != "null" ]] && { echo "$v"; return; }
|
|
132
|
+
fi
|
|
133
|
+
echo 15
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Given a target filesystem path, walk up to find the nearest ancestor that
|
|
137
|
+
# is either a git repo root OR a HQ orchestrator-known repo path. Emits the
|
|
138
|
+
# absolute path of the owning repo (or empty string if none).
|
|
139
|
+
_find_owning_repo() {
|
|
140
|
+
local target
|
|
141
|
+
target=$(_abs_path "$1")
|
|
142
|
+
[[ -z "$target" ]] && return
|
|
143
|
+
# If target doesn't exist, walk up to an existing ancestor first
|
|
144
|
+
while [[ -n "$target" && ! -e "$target" ]]; do
|
|
145
|
+
local parent
|
|
146
|
+
parent=$(dirname "$target")
|
|
147
|
+
[[ "$parent" == "$target" ]] && return
|
|
148
|
+
target="$parent"
|
|
149
|
+
done
|
|
150
|
+
# Now walk up looking for .git
|
|
151
|
+
local dir="$target"
|
|
152
|
+
[[ -f "$dir" ]] && dir=$(dirname "$dir")
|
|
153
|
+
while [[ "$dir" != "/" && -n "$dir" ]]; do
|
|
154
|
+
if [[ -e "$dir/.git" ]]; then
|
|
155
|
+
echo "$dir"
|
|
156
|
+
return
|
|
157
|
+
fi
|
|
158
|
+
dir=$(dirname "$dir")
|
|
159
|
+
done
|
|
160
|
+
echo ""
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Prune stale entries: dead PID OR heartbeat older than stale_minutes.
|
|
164
|
+
# Emits the number of pruned entries on stderr.
|
|
165
|
+
_prune_stale() {
|
|
166
|
+
_ensure_reg
|
|
167
|
+
local stale_min
|
|
168
|
+
stale_min=$(_stale_minutes)
|
|
169
|
+
local now_epoch
|
|
170
|
+
now_epoch=$(date +%s)
|
|
171
|
+
local cutoff=$((now_epoch - stale_min * 60))
|
|
172
|
+
|
|
173
|
+
# Build a jq filter that keeps only entries with fresh heartbeat; then
|
|
174
|
+
# separately filter dead PIDs in bash.
|
|
175
|
+
local tmp="$REG_FILE.tmp.$$"
|
|
176
|
+
local kept='[]'
|
|
177
|
+
local runs
|
|
178
|
+
runs=$(jq -c '.runs[]' "$REG_FILE" 2>/dev/null || true)
|
|
179
|
+
local pruned=0
|
|
180
|
+
if [[ -n "$runs" ]]; then
|
|
181
|
+
while IFS= read -r entry; do
|
|
182
|
+
[[ -z "$entry" ]] && continue
|
|
183
|
+
local pid hb_iso hb_epoch
|
|
184
|
+
pid=$(echo "$entry" | jq -r '.pid // empty')
|
|
185
|
+
hb_iso=$(echo "$entry" | jq -r '.heartbeat_at // .started_at // empty')
|
|
186
|
+
hb_epoch=$(_iso_to_epoch "$hb_iso")
|
|
187
|
+
if ! _is_pid_alive "$pid"; then
|
|
188
|
+
pruned=$((pruned + 1))
|
|
189
|
+
continue
|
|
190
|
+
fi
|
|
191
|
+
if [[ $hb_epoch -lt $cutoff ]]; then
|
|
192
|
+
pruned=$((pruned + 1))
|
|
193
|
+
continue
|
|
194
|
+
fi
|
|
195
|
+
kept=$(echo "$kept" | jq --argjson e "$entry" '. + [$e]')
|
|
196
|
+
done <<< "$runs"
|
|
197
|
+
fi
|
|
198
|
+
jq --argjson runs "$kept" '.runs = $runs' "$REG_FILE" > "$tmp"
|
|
199
|
+
mv "$tmp" "$REG_FILE"
|
|
200
|
+
[[ $pruned -gt 0 ]] && _log "pruned $pruned stale entries"
|
|
201
|
+
return 0
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# ---------- subcommands ----------
|
|
205
|
+
|
|
206
|
+
_cmd_register() {
|
|
207
|
+
local run_id="" pid="" session_id="" command="" project="" repo="" scope="" host=""
|
|
208
|
+
while [[ $# -gt 0 ]]; do
|
|
209
|
+
case "$1" in
|
|
210
|
+
--run-id) run_id="$2"; shift 2 ;;
|
|
211
|
+
--pid) pid="$2"; shift 2 ;;
|
|
212
|
+
--session-id) session_id="$2"; shift 2 ;;
|
|
213
|
+
--command) command="$2"; shift 2 ;;
|
|
214
|
+
--project) project="$2"; shift 2 ;;
|
|
215
|
+
--repo) repo="$2"; shift 2 ;;
|
|
216
|
+
--scope) scope="$2"; shift 2 ;;
|
|
217
|
+
--host) host="$2"; shift 2 ;;
|
|
218
|
+
*) shift ;;
|
|
219
|
+
esac
|
|
220
|
+
done
|
|
221
|
+
[[ -z "$pid" || -z "$command" || -z "$repo" || -z "$scope" ]] && \
|
|
222
|
+
_die "register requires --pid --command --repo --scope"
|
|
223
|
+
repo=$(_abs_path "$repo")
|
|
224
|
+
[[ -z "$run_id" ]] && run_id="$(echo "$command" | tr '/' '-')-$(date +%s)-${project:-none}-$pid"
|
|
225
|
+
[[ -z "$host" ]] && host=$(_hostname)
|
|
226
|
+
|
|
227
|
+
_lock
|
|
228
|
+
trap '_unlock' EXIT
|
|
229
|
+
_ensure_reg
|
|
230
|
+
_prune_stale
|
|
231
|
+
|
|
232
|
+
local now
|
|
233
|
+
now=$(_iso_now)
|
|
234
|
+
local tmp="$REG_FILE.tmp.$$"
|
|
235
|
+
jq --arg run_id "$run_id" \
|
|
236
|
+
--arg pid "$pid" \
|
|
237
|
+
--arg session_id "$session_id" \
|
|
238
|
+
--arg command "$command" \
|
|
239
|
+
--arg project "$project" \
|
|
240
|
+
--arg repo "$repo" \
|
|
241
|
+
--arg scope "$scope" \
|
|
242
|
+
--arg host "$host" \
|
|
243
|
+
--arg now "$now" \
|
|
244
|
+
'.runs |= map(select(.run_id != $run_id)) |
|
|
245
|
+
.runs += [{
|
|
246
|
+
run_id: $run_id,
|
|
247
|
+
pid: ($pid | tonumber),
|
|
248
|
+
session_id: $session_id,
|
|
249
|
+
command: $command,
|
|
250
|
+
project: $project,
|
|
251
|
+
repo_path: $repo,
|
|
252
|
+
scope: $scope,
|
|
253
|
+
host: $host,
|
|
254
|
+
started_at: $now,
|
|
255
|
+
heartbeat_at: $now
|
|
256
|
+
}]' "$REG_FILE" > "$tmp"
|
|
257
|
+
mv "$tmp" "$REG_FILE"
|
|
258
|
+
_unlock
|
|
259
|
+
trap - EXIT
|
|
260
|
+
echo "$run_id"
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_cmd_deregister() {
|
|
264
|
+
local run_id=""
|
|
265
|
+
while [[ $# -gt 0 ]]; do
|
|
266
|
+
case "$1" in
|
|
267
|
+
--run-id) run_id="$2"; shift 2 ;;
|
|
268
|
+
*) shift ;;
|
|
269
|
+
esac
|
|
270
|
+
done
|
|
271
|
+
[[ -z "$run_id" ]] && _die "deregister requires --run-id"
|
|
272
|
+
_lock
|
|
273
|
+
trap '_unlock' EXIT
|
|
274
|
+
_ensure_reg
|
|
275
|
+
local tmp="$REG_FILE.tmp.$$"
|
|
276
|
+
jq --arg run_id "$run_id" '.runs |= map(select(.run_id != $run_id))' "$REG_FILE" > "$tmp"
|
|
277
|
+
mv "$tmp" "$REG_FILE"
|
|
278
|
+
_unlock
|
|
279
|
+
trap - EXIT
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_cmd_heartbeat() {
|
|
283
|
+
local run_id=""
|
|
284
|
+
while [[ $# -gt 0 ]]; do
|
|
285
|
+
case "$1" in
|
|
286
|
+
--run-id) run_id="$2"; shift 2 ;;
|
|
287
|
+
*) shift ;;
|
|
288
|
+
esac
|
|
289
|
+
done
|
|
290
|
+
[[ -z "$run_id" ]] && _die "heartbeat requires --run-id"
|
|
291
|
+
_lock
|
|
292
|
+
trap '_unlock' EXIT
|
|
293
|
+
_ensure_reg
|
|
294
|
+
local now
|
|
295
|
+
now=$(_iso_now)
|
|
296
|
+
local tmp="$REG_FILE.tmp.$$"
|
|
297
|
+
jq --arg run_id "$run_id" --arg now "$now" \
|
|
298
|
+
'.runs |= map(if .run_id == $run_id then .heartbeat_at = $now else . end)' \
|
|
299
|
+
"$REG_FILE" > "$tmp"
|
|
300
|
+
mv "$tmp" "$REG_FILE"
|
|
301
|
+
_unlock
|
|
302
|
+
trap - EXIT
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_cmd_list() {
|
|
306
|
+
_ensure_reg
|
|
307
|
+
_prune_stale_locked
|
|
308
|
+
jq '.runs' "$REG_FILE"
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_cmd_clean_stale() {
|
|
312
|
+
_lock
|
|
313
|
+
trap '_unlock' EXIT
|
|
314
|
+
_prune_stale
|
|
315
|
+
_unlock
|
|
316
|
+
trap - EXIT
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_cmd_owner_of() {
|
|
320
|
+
local path=""
|
|
321
|
+
while [[ $# -gt 0 ]]; do
|
|
322
|
+
case "$1" in
|
|
323
|
+
--path) path="$2"; shift 2 ;;
|
|
324
|
+
*) shift ;;
|
|
325
|
+
esac
|
|
326
|
+
done
|
|
327
|
+
[[ -z "$path" ]] && _die "owner-of requires --path"
|
|
328
|
+
local repo
|
|
329
|
+
repo=$(_find_owning_repo "$path")
|
|
330
|
+
[[ -z "$repo" ]] && { echo ""; return; }
|
|
331
|
+
_ensure_reg
|
|
332
|
+
_prune_stale_locked
|
|
333
|
+
jq --arg repo "$repo" --arg target "$(_abs_path "$path")" \
|
|
334
|
+
'[.runs[] | select(
|
|
335
|
+
(.scope == "repo" and .repo_path == $repo)
|
|
336
|
+
or (.scope | startswith("worktree:")) and
|
|
337
|
+
($target | startswith(.scope | sub("^worktree:"; "")))
|
|
338
|
+
)]' "$REG_FILE"
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# Check if a target path is owned by a foreign run.
|
|
342
|
+
# Exit 0 = clear / self-owned / no owner; exit 2 = blocked by foreign owner.
|
|
343
|
+
_cmd_check() {
|
|
344
|
+
local target="" my_pid="" my_session=""
|
|
345
|
+
while [[ $# -gt 0 ]]; do
|
|
346
|
+
case "$1" in
|
|
347
|
+
--target) target="$2"; shift 2 ;;
|
|
348
|
+
--pid) my_pid="$2"; shift 2 ;;
|
|
349
|
+
--session-id) my_session="$2"; shift 2 ;;
|
|
350
|
+
*) shift ;;
|
|
351
|
+
esac
|
|
352
|
+
done
|
|
353
|
+
[[ -z "$target" ]] && _die "check requires --target"
|
|
354
|
+
|
|
355
|
+
_ensure_reg
|
|
356
|
+
_prune_stale_locked
|
|
357
|
+
|
|
358
|
+
local abs_target
|
|
359
|
+
abs_target=$(_abs_path "$target")
|
|
360
|
+
local repo
|
|
361
|
+
repo=$(_find_owning_repo "$abs_target")
|
|
362
|
+
[[ -z "$repo" ]] && return 0
|
|
363
|
+
|
|
364
|
+
# Collect foreign entries covering this target
|
|
365
|
+
local matches
|
|
366
|
+
matches=$(jq -c --arg repo "$repo" \
|
|
367
|
+
--arg target "$abs_target" \
|
|
368
|
+
--arg mypid "${my_pid:-0}" \
|
|
369
|
+
--arg mysid "${my_session:-__none__}" \
|
|
370
|
+
'[.runs[] | select(
|
|
371
|
+
((.scope == "repo" and .repo_path == $repo)
|
|
372
|
+
or ((.scope | startswith("worktree:")) and
|
|
373
|
+
($target | startswith(.scope | sub("^worktree:"; "")))))
|
|
374
|
+
and ((.pid | tostring) != $mypid)
|
|
375
|
+
and (.session_id != $mysid)
|
|
376
|
+
)]' "$REG_FILE" 2>/dev/null || echo '[]')
|
|
377
|
+
|
|
378
|
+
local n
|
|
379
|
+
n=$(echo "$matches" | jq 'length')
|
|
380
|
+
if [[ "${n:-0}" -eq 0 ]]; then
|
|
381
|
+
return 0
|
|
382
|
+
fi
|
|
383
|
+
|
|
384
|
+
# Emit one summary line per owner to stderr for the calling hook
|
|
385
|
+
echo "$matches" | jq -r '.[] |
|
|
386
|
+
"run_id=\(.run_id) pid=\(.pid) command=\(.command) project=\(.project) scope=\(.scope) repo=\(.repo_path) started=\(.started_at) heartbeat=\(.heartbeat_at)"' >&2
|
|
387
|
+
return 2
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# ---------- dispatch ----------
|
|
391
|
+
|
|
392
|
+
main() {
|
|
393
|
+
local sub="${1:-}"
|
|
394
|
+
[[ -z "$sub" ]] && _die "usage: repo-run-registry.sh <subcommand> [args...]"
|
|
395
|
+
shift
|
|
396
|
+
case "$sub" in
|
|
397
|
+
register) _cmd_register "$@" ;;
|
|
398
|
+
deregister) _cmd_deregister "$@" ;;
|
|
399
|
+
heartbeat) _cmd_heartbeat "$@" ;;
|
|
400
|
+
list) _cmd_list "$@" ;;
|
|
401
|
+
check) _cmd_check "$@" ;;
|
|
402
|
+
clean-stale) _cmd_clean_stale "$@" ;;
|
|
403
|
+
owner-of) _cmd_owner_of "$@" ;;
|
|
404
|
+
*) _die "unknown subcommand: $sub" ;;
|
|
405
|
+
esac
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
main "$@"
|