@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-matters.io/agent-harness",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Spec-Driven Development with Claude Code: gates, task loop and standards installed into a product repository",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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 Anthropic API key cannot serve it: no credit, a spend or usage limit, a
36
- # rate limit, billing, or an invalid key. Only an errored session counts, so a
37
- # task about authentication or billing never matches by its own words.
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
- node -e "$harness_session_js"'
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
- const text = Object.keys(d).length
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
- process.exitCode = /credit balance|spend(ing)? limit|billing|usage limit|rate limit|out of usage|invalid api key|authentication/i.test(text) ? 0 : 1;' "$1" 2>/dev/null
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
- if [[ -z "$key" ]]; then
66
- timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
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 key was refused; repeating the session on the plan login" >&2
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
- grep -qiE "hit your [^\"]{0,40}limit|usage_limit_reached|usage limit reached|out of usage credits|credit balance is too low" "$1" 2>/dev/null
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 (account usage limit; run-tasks discarded the partial attempt)
15
- # → notify, wait HARNESS_RESUME_INTERVAL seconds (default 900), try again
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:-900}" log=".harness/logs/$change/runner.log" rc pr every reason
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
- .harness/run-tasks.sh "$change" --open-pr "$@" >>"$log" 2>&1
79
- rc=$?
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 by the account usage limit; retrying every $every"
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
+ }