@data-matters.io/agent-harness 0.1.6 → 0.1.8
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/package.json +1 -1
- package/templates/.harness/lib.sh +60 -13
- package/templates/.harness/loop.sh +15 -6
- package/templates/.harness/quota.sh +56 -0
package/package.json
CHANGED
|
@@ -23,6 +23,10 @@ harness_load_env() {
|
|
|
23
23
|
set +a
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
# The shared detection rule (ADR 0025, section 8): quota and gateway failures.
|
|
27
|
+
# shellcheck source-path=SCRIPTDIR source=quota.sh
|
|
28
|
+
. "$(dirname "${BASH_SOURCE[0]}")/quota.sh"
|
|
29
|
+
|
|
26
30
|
# HARNESS_CLAUDE: the command that starts Claude Code for an agent session (an
|
|
27
31
|
# array, so it works after `timeout`). The bot token and chat id are left out of
|
|
28
32
|
# the session's environment: only notify.sh needs them, and it reads the env file
|
|
@@ -32,17 +36,39 @@ HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID -u
|
|
|
32
36
|
|
|
33
37
|
# ------------------------------------------- optional API key (ADR 0020) --
|
|
34
38
|
# harness_api_refused <session.json>: the session ended on an error that says
|
|
35
|
-
# the
|
|
36
|
-
#
|
|
37
|
-
#
|
|
39
|
+
# the API cannot serve it: no credit, a quota, a spend or usage limit, billing,
|
|
40
|
+
# an invalid key, or the gateway itself failing (503 or another 5xx, a
|
|
41
|
+
# connection failure, a timeout: ADR 0025, section 8a). A transient 429 ("try
|
|
42
|
+
# again in a few seconds") is none of these. Only an errored session counts, so
|
|
43
|
+
# a task about authentication or billing never matches by its own words.
|
|
38
44
|
harness_api_refused() {
|
|
39
|
-
|
|
45
|
+
local text
|
|
46
|
+
text="$(node -e "$harness_session_js"'
|
|
40
47
|
const raw = (() => { try { return require("fs").readFileSync(process.argv[1], "utf8"); } catch { return ""; } })();
|
|
41
48
|
const d = session(process.argv[1]);
|
|
42
|
-
|
|
49
|
+
process.stdout.write(Object.keys(d).length
|
|
43
50
|
? (d.is_error ? [d.result, d.api_error].filter((x) => typeof x === "string").join(" ") : "")
|
|
44
|
-
: raw;
|
|
45
|
-
|
|
51
|
+
: raw);' "$1" 2>/dev/null)" || return 1
|
|
52
|
+
[[ -n "$text" ]] || return 1
|
|
53
|
+
harness_api_unreachable "$text" && return 0
|
|
54
|
+
harness_quota_transient "$text" && return 1
|
|
55
|
+
harness_quota_exhausted "$text" && return 0
|
|
56
|
+
grep -qiE "credit balance|spend(ing)? limit|billing|usage limit|rate limit|out of usage|invalid api key|authentication" <<<"$text"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------ machine mode (ADR 0025) --
|
|
60
|
+
# harness_mode: api, plan or blocked, from the machine's mode file
|
|
61
|
+
# (HARNESS_MODE_FILE, default ~/.local/state/harness/mode; its only writer is
|
|
62
|
+
# the watchdog). A missing, unreadable or unrecognised file is api, the
|
|
63
|
+
# preferred mode.
|
|
64
|
+
harness_mode() {
|
|
65
|
+
local f="${HARNESS_MODE_FILE:-$HOME/.local/state/harness/mode}" line mode=api
|
|
66
|
+
if [[ -r "$f" ]]; then
|
|
67
|
+
while IFS= read -r line; do
|
|
68
|
+
[[ "$line" == mode=* ]] && mode="${line#mode=}"
|
|
69
|
+
done <"$f"
|
|
70
|
+
fi
|
|
71
|
+
case "$mode" in api|plan|blocked) echo "$mode" ;; *) echo api ;; esac
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
# harness_claude_session <timeout-seconds> <session.json> <claude args...>: runs
|
|
@@ -59,11 +85,30 @@ harness_api_refused() {
|
|
|
59
85
|
# sees it. A key from a gateway without its base URL would be sent to the
|
|
60
86
|
# Anthropic API and refused.
|
|
61
87
|
harness_claude_session() {
|
|
62
|
-
local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" base="${HARNESS_ANTHROPIC_BASE_URL:-}" rc=0 prompt
|
|
88
|
+
local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" base="${HARNESS_ANTHROPIC_BASE_URL:-}" rc=0 prompt mode unset=()
|
|
63
89
|
shift 2
|
|
64
90
|
rm -f "$out.source"
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
# The machine mode (ADR 0025) names the authentication to start on: api → the
|
|
92
|
+
# key and its base URL, plan → the account login with both removed, blocked →
|
|
93
|
+
# nothing runs. The fallback below stays as the net for a refusal that
|
|
94
|
+
# happens after the mode was read.
|
|
95
|
+
mode="$(harness_mode)"
|
|
96
|
+
if [[ "$mode" == blocked ]]; then
|
|
97
|
+
echo '{"type":"result","is_error":true,"api_error":"usage_limit_reached","result":"harness: the machine is blocked (the API does not respond and the account is out of usage); usage limit reached"}' >"$out"
|
|
98
|
+
return 1
|
|
99
|
+
fi
|
|
100
|
+
if [[ -z "$key" || "$mode" == plan ]]; then
|
|
101
|
+
# No key configured, or the API is down: the session is the account login,
|
|
102
|
+
# on the Anthropic API. A base URL left in the environment would point that
|
|
103
|
+
# login at a gateway, and a key must not reach it.
|
|
104
|
+
[[ -z "$key" ]] || unset=(-u ANTHROPIC_API_KEY)
|
|
105
|
+
[[ -z "$base" ]] || unset+=(-u ANTHROPIC_BASE_URL)
|
|
106
|
+
if (( ${#unset[@]} )); then
|
|
107
|
+
timeout "$secs" env "${unset[@]}" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
|
|
108
|
+
else
|
|
109
|
+
timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
|
|
110
|
+
fi
|
|
111
|
+
[[ -z "$key" ]] || echo plan >"$out.source"
|
|
67
112
|
return "$rc"
|
|
68
113
|
fi
|
|
69
114
|
prompt="$(mktemp)"
|
|
@@ -77,7 +122,7 @@ harness_claude_session() {
|
|
|
77
122
|
K="$key" node -e 'const fs = require("fs"); const f = process.argv[1];
|
|
78
123
|
try { const s = fs.readFileSync(f, "utf8"); if (s.includes(process.env.K)) fs.writeFileSync(f, s.split(process.env.K).join("[redacted]")); } catch {}' "$out" 2>/dev/null || true
|
|
79
124
|
if (( rc != 124 )) && harness_api_refused "$out"; then
|
|
80
|
-
echo "harness: the API
|
|
125
|
+
echo "harness: the API did not serve the session (refused, or the gateway failed); repeating it on the plan login" >&2
|
|
81
126
|
rc=0
|
|
82
127
|
if [[ -n "$base" ]]; then
|
|
83
128
|
timeout "$secs" env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
|
|
@@ -157,9 +202,11 @@ harness_claude_flags() {
|
|
|
157
202
|
# limit or on missing usage credits ("You're out of usage credits. Switch to
|
|
158
203
|
# another model..."), not on the work. Claude Code names the window in the
|
|
159
204
|
# message ("You've hit your 5-hour limit", "...your weekly limit", "...your
|
|
160
|
-
# session limit") and sets api_error to usage_limit_reached, so both are matched
|
|
205
|
+
# session limit") and sets api_error to usage_limit_reached, so both are matched;
|
|
206
|
+
# the gateway's Portuguese quota message is matched too, and its transient 429
|
|
207
|
+
# is not (quota.sh, ADR 0025 section 8).
|
|
161
208
|
harness_usage_error() {
|
|
162
|
-
|
|
209
|
+
harness_quota_exhausted "$(cat -- "$1" 2>/dev/null)"
|
|
163
210
|
}
|
|
164
211
|
|
|
165
212
|
# harness_level_descend <level>: the level one below, for the fallback after a
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
# The supervisor runs run-tasks.sh --open-pr and reacts to its exit code:
|
|
12
12
|
# 0 done → notify with the PR link
|
|
13
13
|
# 2 escalated → notify; a human takes over
|
|
14
|
-
# 3 paused (
|
|
15
|
-
#
|
|
14
|
+
# 3 paused (usage limit, or the machine is blocked: the API does not respond
|
|
15
|
+
# and the account is out of usage; run-tasks discarded the partial attempt)
|
|
16
|
+
# → notify, wait HARNESS_RESUME_INTERVAL seconds (default 3600, in step
|
|
17
|
+
# with the watchdog's hourly probe, ADR 0025), try again
|
|
16
18
|
# 4 stopped between two tasks (`stop --after-task`) → notify; restart later
|
|
17
19
|
# run-tasks works in the change's own git worktree (ADR 0012): the checkout this
|
|
18
20
|
# runs from is never switched or cleaned, so specs can be written meanwhile.
|
|
@@ -70,13 +72,20 @@ close_inherited_fds() {
|
|
|
70
72
|
supervise() {
|
|
71
73
|
local change="$1"; shift
|
|
72
74
|
close_inherited_fds
|
|
73
|
-
local interval="${HARNESS_RESUME_INTERVAL:-
|
|
75
|
+
local interval="${HARNESS_RESUME_INTERVAL:-3600}" log=".harness/logs/$change/runner.log" rc pr every reason
|
|
74
76
|
mkdir -p ".harness/logs/$change"
|
|
75
77
|
.harness/notify.sh started "Loop started for change $change"
|
|
76
78
|
while true; do
|
|
77
79
|
set +e
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
if [[ "$(harness_mode)" == blocked ]]; then
|
|
81
|
+
# The machine is blocked (ADR 0025): pause as for a usage limit and look
|
|
82
|
+
# again after the interval, so the loop resumes when the mode changes.
|
|
83
|
+
echo "loop: the machine is blocked; not starting run-tasks" >>"$log"
|
|
84
|
+
rc=3
|
|
85
|
+
else
|
|
86
|
+
.harness/run-tasks.sh "$change" --open-pr "$@" >>"$log" 2>&1
|
|
87
|
+
rc=$?
|
|
88
|
+
fi
|
|
80
89
|
set -e
|
|
81
90
|
case "$rc" in
|
|
82
91
|
0)
|
|
@@ -89,7 +98,7 @@ supervise() {
|
|
|
89
98
|
break ;;
|
|
90
99
|
3)
|
|
91
100
|
if (( interval >= 60 )); then every="$((interval / 60)) min"; else every="${interval}s"; fi
|
|
92
|
-
.harness/notify.sh paused "Change $change paused
|
|
101
|
+
.harness/notify.sh paused "Change $change paused (usage limit, or the API and the account are unavailable); retrying every $every"
|
|
93
102
|
sleep "$interval"
|
|
94
103
|
.harness/notify.sh resumed "Resuming change $change" ;;
|
|
95
104
|
4)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Quota and gateway-failure detection (ADR 0025, sections 5 and 8). Source it;
|
|
2
|
+
# do not run it. Standalone on purpose: no dependency on lib.sh, so that a
|
|
3
|
+
# wrapper can source it without pulling in the harness.
|
|
4
|
+
# shellcheck shell=bash
|
|
5
|
+
#
|
|
6
|
+
# Two questions, answered separately:
|
|
7
|
+
# (a) did the API stop serving? harness_api_unreachable <text>
|
|
8
|
+
# a gateway failure (5xx such as 503, a connection failure, a timeout);
|
|
9
|
+
# a quota message (below) also means the API does not serve.
|
|
10
|
+
# (b) is the message a quota exhaustion? harness_quota_exhausted <text>
|
|
11
|
+
# a quota marker and no transient marker. A 429 alone is not enough in
|
|
12
|
+
# either direction: both gateway messages carry the same structured
|
|
13
|
+
# fields, so only the text tells them apart.
|
|
14
|
+
# A transient 429 (harness_quota_transient) is neither: it may be retried and
|
|
15
|
+
# changes nothing.
|
|
16
|
+
#
|
|
17
|
+
# Matching is accent-insensitive and case-folded: the gateway's Portuguese has
|
|
18
|
+
# no accents ("Voce", "Sera", "faca"), the accented spelling is covered too.
|
|
19
|
+
|
|
20
|
+
# harness_quota_normalize <text>: the text with accents stripped and case folded.
|
|
21
|
+
harness_quota_normalize() {
|
|
22
|
+
local t="$1"
|
|
23
|
+
t="${t,,}"
|
|
24
|
+
local from=(á à â ã ä é è ê ë í ì î ï ó ò ô õ ö ú ù û ü ç ñ Á À Â Ã Ä É È Ê Ë Í Ì Î Ï Ó Ò Ô Õ Ö Ú Ù Û Ü Ç Ñ)
|
|
25
|
+
local to=(a a a a a e e e e i i i i o o o o o u u u u c n a a a a a e e e e i i i i o o o o o u u u u c n)
|
|
26
|
+
local i
|
|
27
|
+
for i in "${!from[@]}"; do t="${t//"${from[$i]}"/"${to[$i]}"}"; done
|
|
28
|
+
printf '%s' "$t"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Quota markers, on normalized text (ADR 0025, section 8).
|
|
32
|
+
harness_quota_markers='limite de uso do seu plano|cota semanal|sera resetado em|faca upgrade do plano|hit your [^"]{0,40}limit|usage limit reached|usage_limit_reached|out of usage credits|credit balance is too low|spend limit'
|
|
33
|
+
|
|
34
|
+
# Transient markers, on normalized text: the request may be retried.
|
|
35
|
+
harness_quota_transient_markers='o consumo da requisicao anterior esta sendo atualizado|tente novamente em alguns segundos|temporarily at capacity|repeated [0-9]+ overloaded errors'
|
|
36
|
+
|
|
37
|
+
# Gateway failures, on normalized text: the API does not answer.
|
|
38
|
+
harness_gateway_markers='api error:? \(?5[0-9]{2}|(status|error|http)[ :=]+5[0-9]{2}\b|"api_error_status": ?5[0-9]{2}|\b50[0-9] (service unavailable|bad gateway|gateway time-?out)|service unavailable|bad gateway|gateway time-?out|econnrefused|econnreset|etimedout|enotfound|eai_again|socket hang up|fetch failed|unable to connect|connection error|connection refused|request timed out'
|
|
39
|
+
|
|
40
|
+
# harness_quota_transient <text>: a retryable condition, not a quota.
|
|
41
|
+
harness_quota_transient() {
|
|
42
|
+
grep -qE -- "$harness_quota_transient_markers" <<<"$(harness_quota_normalize "$1")"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# harness_quota_exhausted <text>: the quota is gone (a quota marker and no
|
|
46
|
+
# transient marker).
|
|
47
|
+
harness_quota_exhausted() {
|
|
48
|
+
local t; t="$(harness_quota_normalize "$1")"
|
|
49
|
+
grep -qE -- "$harness_quota_markers" <<<"$t" && ! grep -qE -- "$harness_quota_transient_markers" <<<"$t"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# harness_api_unreachable <text>: the gateway failed (503 or another 5xx, a
|
|
53
|
+
# connection failure, a timeout): the API does not answer, whatever the quota.
|
|
54
|
+
harness_api_unreachable() {
|
|
55
|
+
grep -qE -- "$harness_gateway_markers" <<<"$(harness_quota_normalize "$1")"
|
|
56
|
+
}
|