@data-matters.io/agent-harness 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
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
@@ -31,72 +35,58 @@ harness_load_env() {
31
35
  HARNESS_CLAUDE=(env -u HARNESS_TELEGRAM_BOT_TOKEN -u HARNESS_TELEGRAM_CHAT_ID -u HARNESS_ANTHROPIC_API_KEY -u HARNESS_ANTHROPIC_BASE_URL claude)
32
36
 
33
37
  # ------------------------------------------- optional API key (ADR 0020) --
34
- # 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.
38
- harness_api_refused() {
38
+ # harness_session_error_text <session.json>: the text of an errored session (its
39
+ # result and api_error), or the raw output when it is not JSON. Empty when the
40
+ # session did not fail.
41
+ harness_session_error_text() {
39
42
  node -e "$harness_session_js"'
40
43
  const raw = (() => { try { return require("fs").readFileSync(process.argv[1], "utf8"); } catch { return ""; } })();
41
44
  const d = session(process.argv[1]);
42
- const text = Object.keys(d).length
45
+ process.stdout.write(Object.keys(d).length
43
46
  ? (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
47
+ : raw);' "$1" 2>/dev/null
46
48
  }
47
49
 
48
- # harness_claude_session <timeout-seconds> <session.json> <claude args...>: runs
49
- # one headless Claude Code session (the prompt on stdin) and writes its output
50
- # (stdout and stderr) to <session.json>; returns the exit status of the run
51
- # that produced it (124: timed out). Without HARNESS_ANTHROPIC_API_KEY this is
52
- # the account login, exactly as before. With it, the session runs on the API
53
- # first (the key goes to that process only, through its environment, never its
54
- # arguments or a log); when the API refuses it (harness_api_refused), the SAME
55
- # attempt repeats on the plan login. Which one produced the output is written to
56
- # <session.json>.source (api|plan), which the usage ledger reads.
57
- # HARNESS_ANTHROPIC_BASE_URL is the optional base URL of that key (a gateway):
58
- # it reaches the API run only, as ANTHROPIC_BASE_URL, and the plan run never
59
- # sees it. A key from a gateway without its base URL would be sent to the
60
- # Anthropic API and refused.
61
- harness_claude_session() {
62
- local secs="$1" out="$2" key="${HARNESS_ANTHROPIC_API_KEY:-}" base="${HARNESS_ANTHROPIC_BASE_URL:-}" rc=0 prompt
63
- shift 2
64
- rm -f "$out.source"
65
- if [[ -z "$key" ]]; then
66
- # No key configured: the session is the account login, on the Anthropic API.
67
- # A base URL left in the environment would point that login at a gateway.
68
- if [[ -n "$base" ]]; then
69
- timeout "$secs" env -u ANTHROPIC_BASE_URL "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
70
- else
71
- timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
72
- fi
73
- return "$rc"
74
- fi
75
- prompt="$(mktemp)"
76
- cat >"$prompt"
77
- if [[ -n "$base" ]]; then
78
- ANTHROPIC_API_KEY="$key" ANTHROPIC_BASE_URL="$base" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
79
- else
80
- ANTHROPIC_API_KEY="$key" timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
81
- fi
82
- # Whatever the session printed, the key itself never stays in the log.
83
- K="$key" node -e 'const fs = require("fs"); const f = process.argv[1];
84
- 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
85
- if (( rc != 124 )) && harness_api_refused "$out"; then
86
- echo "harness: the API key was refused; repeating the session on the plan login" >&2
87
- rc=0
88
- if [[ -n "$base" ]]; then
89
- timeout "$secs" env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
90
- else
91
- timeout "$secs" env -u ANTHROPIC_API_KEY "${HARNESS_CLAUDE[@]}" "$@" <"$prompt" >"$out" 2>&1 || rc=$?
92
- fi
93
- echo plan >"$out.source"
94
- else
50
+ # harness_api_refused <session.json>: the session ended on an error that says
51
+ # the API cannot serve it: no credit, a quota, a spend or usage limit, billing,
52
+ # an invalid key, or the gateway itself failing (503 or another 5xx, a
53
+ # connection failure, a timeout: ADR 0025, section 8a). A transient 429 ("try
54
+ # again in a few seconds") is none of these. Only an errored session counts, so
55
+ # a task about authentication or billing never matches by its own words.
56
+ harness_api_refused() {
57
+ local text
58
+ text="$(harness_session_error_text "$1")" || return 1
59
+ [[ -n "$text" ]] || return 1
60
+ harness_api_unreachable "$text" && return 0
61
+ harness_quota_transient "$text" && return 1
62
+ harness_quota_exhausted "$text" && return 0
63
+ grep -qiE "credit balance|spend(ing)? limit|billing|usage limit|rate limit|out of usage|invalid api key|authentication" <<<"$text"
64
+ }
65
+
66
+ # ----------------------------------- machine layer shim (ADR 0026, section 9) --
67
+ # The session wrapper and the machine mode live in the machine layer,
68
+ # machine/session.sh, in the VPS runtime (HARNESS_WATCH_RUNTIME, default
69
+ # ~/.local/share/agent-harness/watch). HARNESS_MACHINE_API=1 is the contract.
70
+ # Without a runtime (CI, a laptop, a clone elsewhere) a session is a plain claude
71
+ # with the caller's own environment and the mode is api: the degraded fallback of
72
+ # ADR 0025, section 8. A key from HARNESS_ANTHROPIC_API_KEY is not used then.
73
+ # shellcheck disable=SC2034 # the contract marker, read by the runtime
74
+ HARNESS_MACHINE_API=1
75
+ harness_machine_session_file() { printf '%s' "${HARNESS_WATCH_RUNTIME:-$HOME/.local/share/agent-harness/watch}/machine/session.sh"; }
76
+ if [[ -r "$(harness_machine_session_file)" ]]; then
77
+ # shellcheck source=/dev/null
78
+ . "$(harness_machine_session_file)"
79
+ else
80
+ harness_mode() { echo api; }
81
+ harness_claude_session() { # <timeout-seconds> <session.json> <claude args...>
82
+ local secs="$1" out="$2" rc=0
83
+ shift 2
84
+ rm -f "$out.source"
85
+ timeout "$secs" "${HARNESS_CLAUDE[@]}" "$@" >"$out" 2>&1 || rc=$?
95
86
  echo api >"$out.source"
96
- fi
97
- rm -f "$prompt"
98
- return "$rc"
99
- }
87
+ return "$rc"
88
+ }
89
+ fi
100
90
 
101
91
  # Worktree where the task loop runs a change (ADR 0012), outside the checkout so
102
92
  # lint, tests and type-checks of the main checkout never see it:
@@ -163,9 +153,11 @@ harness_claude_flags() {
163
153
  # limit or on missing usage credits ("You're out of usage credits. Switch to
164
154
  # another model..."), not on the work. Claude Code names the window in the
165
155
  # message ("You've hit your 5-hour limit", "...your weekly limit", "...your
166
- # session limit") and sets api_error to usage_limit_reached, so both are matched.
156
+ # session limit") and sets api_error to usage_limit_reached, so both are matched;
157
+ # the gateway's Portuguese quota message is matched too, and its transient 429
158
+ # is not (quota.sh, ADR 0025 section 8).
167
159
  harness_usage_error() {
168
- 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
160
+ harness_quota_exhausted "$(cat -- "$1" 2>/dev/null)"
169
161
  }
170
162
 
171
163
  # 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)
@@ -28,5 +28,6 @@ TASK_ci_fix_retry=R3 # failing checks after a failed fix (watcher, ADR 0019)
28
28
  TASK_review_comments=R2 # owner comments on implementation/sync PRs (ADR 0019, 1b)
29
29
  TASK_diagnosis=R3 # explaining an escalation
30
30
  TASK_review=R3 # PR review
31
+ TASK_issue_fix=R3 # a fix for an open harness-fault issue (ADR 0027, decision 5); unused until fix mode is enabled
31
32
  TASK_archive_fix=R1 # editorial split of a merged requirement over 500 characters (ADR 0022), gate-verified
32
33
  TASK_routine=R1 # hand edits around sync and archive
@@ -0,0 +1,70 @@
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. This is the
38
+ # one list for the whole system (ADR 0026, section 6): the machine library
39
+ # (vps-bootstrap) loads this file and keeps a copy only as a degraded fallback,
40
+ # and a test there fails if the two disagree.
41
+ 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|overloaded|upstream|econnrefused|econnreset|etimedout|enotfound|eai_again|socket hang up|fetch failed|unable to connect|connection (error|refused|reset|closed)|timed? ?out|could not resolve|network error|context validation'
42
+
43
+ # harness_quota_transient <text>: a retryable condition, not a quota.
44
+ harness_quota_transient() {
45
+ grep -qE -- "$harness_quota_transient_markers" <<<"$(harness_quota_normalize "$1")"
46
+ }
47
+
48
+ # harness_quota_exhausted <text>: the quota is gone (a quota marker and no
49
+ # transient marker).
50
+ harness_quota_exhausted() {
51
+ local t; t="$(harness_quota_normalize "$1")"
52
+ grep -qE -- "$harness_quota_markers" <<<"$t" && ! grep -qE -- "$harness_quota_transient_markers" <<<"$t"
53
+ }
54
+
55
+ # harness_api_unreachable <text>: the gateway failed (503 or another 5xx, a
56
+ # connection failure, a timeout): the API does not answer, whatever the quota.
57
+ harness_api_unreachable() {
58
+ grep -qE -- "$harness_gateway_markers" <<<"$(harness_quota_normalize "$1")"
59
+ }
60
+
61
+ # harness_api_fault_class <text>: how a session error counts for the machine
62
+ # mode (ADR 0025 section 8, ADR 0026 sections 3 and 6). Prints one word:
63
+ # transient a passing failure: counts only after several in a row
64
+ # down the API does not serve: a quota, or a gateway failure
65
+ # none not a failure of the API
66
+ harness_api_fault_class() {
67
+ if harness_quota_transient "$1"; then printf 'transient'; return 0; fi
68
+ if harness_quota_exhausted "$1" || harness_api_unreachable "$1"; then printf 'down'; return 0; fi
69
+ printf 'none'
70
+ }