harnex 0.30.0 → 0.30.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4b50e456b0b9be717e037a0596b942273e2e4c192b80d697fc502a66cc4c220
4
- data.tar.gz: 77ed9e03656bfff5f68064bb4dcb64f898d1de6b5d599ad30450ac51f8a1938e
3
+ metadata.gz: e3869562568a9927b6a413b5536ee9c489940ce0a8cf9ac6c001072b005ae760
4
+ data.tar.gz: 1203d0e9e327d56ccb63a119bc27489c417dce551e6be530772b2477e6f592b2
5
5
  SHA512:
6
- metadata.gz: 85ca4102b12fe2a0e109c1fdfc643c96d80677b267c0b658cb4ff5787a72075e0d77e9d98a29bf401fa5975502cfc5fca891fe62c63ef51a92b8f626e2c60d04
7
- data.tar.gz: 39b143e75ade8b68254742f6ea23eee43fde615c01de9c273d221b681b8b913f7c35e87b6bf9a79f84e290602790641fdb5a96e84c7a75fedad602b1ef6718d6
6
+ metadata.gz: 5f445333438f47be5c9f8f8eef6a90c3ce4844d6d4c398ae9f9a1568d4750de3313f741266b325b07d01fb941599473b6b143c827b34aa8608cab47a7f2bd1db
7
+ data.tar.gz: f5bc3dde8e4ab3b666989418293ce304d53dbcbd4752baf7d17ad1afad86e39af2d8b56bcf28ace274caf5440892ac7111e4fb8dfd02581e9de38757a6659d7e
data/CHANGELOG.md CHANGED
@@ -1,8 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.1] - 2026-10-28 | 11:16 AM | +04
4
+
5
+ Holm Queue 174 lost a night to dead lanes reading `running` and refusals
6
+ reading "Unknown error": #93.
7
+
8
+ ### Fixed
9
+
10
+ - **Settled lanes no longer read `running` in machine listings** (#93).
11
+ `harnex lanes --json` reports `done`/`failed` independently of
12
+ `collected`; listing does not collect results. The table, which MCP
13
+ `lanes` also returns, keeps its wording, including `finished, uncollected`.
14
+ - **Pi provider errors have actionable outcomes** (#93). Refusal errors
15
+ (`cyber_policy`, content filter/policy, cybersecurity-risk flags) settle as
16
+ `policy_refusal`; retry guidance requires a rephrased brief or another
17
+ route. Pi's exact `Unknown error (no error details in response)` settles as
18
+ `provider_error_unexplained`, with guidance to inspect provider/gateway
19
+ logs before retrying. Neither suggests continuing the same task unchanged.
20
+ Both classes remain intact in owner-watch events and count toward the
21
+ default orchestration failure budget. `provider_capacity` is unchanged.
22
+
3
23
  ## [0.30.0] - 2026-10-25 | 02:55 AM | IST
4
24
 
5
- Holm's first nights with lanes (Holm Queue 167): #89–#92. Local install.
25
+ Holm's first nights with lanes (Holm Queue 167): #89–#92. Published to RubyGems.
6
26
 
7
27
  ### Fixed
8
28
 
data/TECHNICAL.md CHANGED
@@ -423,7 +423,10 @@ not a new runtime.
423
423
  base sha, owner (`CLAUDE_CODE_SESSION_ID`/`PI_SESSION_ID`), requested model,
424
424
  Pi `agent_session_id`, accumulated cost, launch time and the last turn's result.
425
425
  States: `running` (a turn is out and uncollected), `done`, `failed`, `landed`,
426
- `discarded`.
426
+ `discarded`. `lanes --json` and MCP `lanes` project current settlement from
427
+ task events (or fresh terminal evidence): `done`/`failed` even before
428
+ collection, with separate `collected` and process-liveness `live` fields.
429
+ Listing is read-only; pending results and the table wording are unchanged.
427
430
  - **Launching:** the verbs shell out to the low-level CLI. `delegate` runs
428
431
  `harnex run pi --id NAME --tmux NAME --cwd WORKDIR --root ROOT --auto-stop`
429
432
  with a brief file (`--context-file`); with `--wait-done` it blocks on the
@@ -436,7 +439,12 @@ not a new runtime.
436
439
  <agent_session_id>`, so Pi resumes the conversation.
437
440
  - **Settling:** after a turn, the lane reads the launch's `usage` event (cost,
438
441
  `agent_session_id`) and records the result; the footer adds commits and a
439
- diffstat from git (`base_sha..HEAD`).
442
+ diffstat from git (`base_sha..HEAD`). Pi error text can classify
443
+ `policy_refusal` (retry with a rephrased brief or another route) or
444
+ `provider_error_unexplained` (inspect provider/gateway logs first). These
445
+ failure classes are preserved in `watch --owner`; `provider_capacity`
446
+ retains its adapter-retries-exhausted meaning. Full vocabulary:
447
+ [dispatch telemetry](docs/dispatch-telemetry.md).
440
448
  - **Worktrees:** `.harnex/worktrees/NAME` on branch `harnex/NAME`; `.harnex/`
441
449
  is added to `<git-common-dir>/info/exclude` on the first write.
442
450
  `land` merges with `git merge --no-edit`, leaves conflicts in progress, and
@@ -50,6 +50,28 @@ The effective value is the first-class `harnex run --phase TEXT` value, or
50
50
  The allowlist must be an array of non-empty strings. Policy must be `warn` or
51
51
  `reject`.
52
52
 
53
+ ## Lane worktrees
54
+
55
+ `harnex delegate --worktree NAME` creates `.harnex/worktrees/NAME` on branch
56
+ `harnex/NAME`. Repositories that keep worktrees elsewhere can set templates:
57
+
58
+ ```json
59
+ {
60
+ "lanes": {
61
+ "worktree_path": "../night-{mm}{dd}_{name}",
62
+ "branch": "night/{mm}{dd}_{name}"
63
+ }
64
+ }
65
+ ```
66
+
67
+ - Placeholders: `{name}` (the lane), `{yyyy}`, `{mm}`, `{dd}` (the day the
68
+ lane starts). Each template must contain `{name}`.
69
+ - `worktree_path` is relative to the main checkout; `branch` must be a valid
70
+ branch name.
71
+ - Either key may be left out to keep its default. `land` and `discard` follow
72
+ each lane's recorded path and branch, so changing the templates does not
73
+ strand lanes already started. See [Lanes](lanes.md).
74
+
53
75
  ## Events, output, and receipt retention
54
76
 
55
77
  Per-session event JSONL, output transcripts, and generated proof receipts live
@@ -345,10 +345,16 @@ facts prove the delta, not semantic quality or human authorship.
345
345
  - **Completion:** `completed_with_proof`, `completed_with_activity`.
346
346
  - **Proof defects:** `completed_no_activity`, `report_missing`,
347
347
  `report_invalid`, `report_rejected`.
348
- - **Process:** `task_failed` (the turn failed), `provider_capacity` (the turn
349
- failed after the adapter's own provider retries ran out, from Pi's
350
- structured `auto_retry_end success:false`; capacity or rate limits rather
351
- than the work), `boot_failure` (structured
348
+ - **Provider:** `provider_capacity` (the turn failed after the adapter's own
349
+ provider retries ran out, from Pi's structured `auto_retry_end success:false`;
350
+ capacity or rate limits rather than the work), `policy_refusal` (Pi's error
351
+ text names `cyber_policy`, content filter/policy, or a cybersecurity-risk
352
+ flag; retry needs a rephrased brief or another route),
353
+ `provider_error_unexplained` (Pi's exact `Unknown error (no error details in
354
+ response)`; inspect provider/gateway logs before retrying). Explicit error
355
+ classes take precedence over retry exhaustion; they do not infer a refusal
356
+ from an unexplained error. `watch --owner` carries these classes unchanged.
357
+ - **Process:** `task_failed` (the turn failed), `boot_failure` (structured
352
358
  transport died before its first turn), `permission_prompt` (ended on a
353
359
  trust/confirmation prompt the adapter recognizes), `timeout` (`--max-runtime`
354
360
  stopped running work, or exit 124), `stopped_by_caller` (a manual stop
@@ -375,7 +381,8 @@ initial workers do not join families. Start rows are not counted. An optional
375
381
  adds a `failure_budget` verdict and exits 3 once N fatal failures are counted
376
382
  (1 is an error, 2 a usage error; a missing `--dispatch` file is an error).
377
383
  By default the fatal classes are `completed_no_activity`, `task_failed`,
378
- `provider_capacity`, `boot_failure`, `permission_prompt`, `timeout`, and `process_failed`; proof
384
+ `provider_capacity`, `policy_refusal`, `provider_error_unexplained`,
385
+ `boot_failure`, `permission_prompt`, `timeout`, and `process_failed`; proof
379
386
  defects, caller stops and `unknown` are not fatal.
380
387
  Harnex supplies the ledger only: the caller decides what to do on a breach.
381
388
 
data/docs/lanes.md CHANGED
@@ -199,7 +199,14 @@ interjections.
199
199
  For provider and model failures (not found, rate limited, out of capacity) the
200
200
  footer suggests continuing on Pi's default model. `reply --model` resumes the
201
201
  same session, so the task is not lost. For `ask`, `--fallback-model` does this
202
- automatically.
202
+ automatically on `provider_capacity` only.
203
+
204
+ A provider refusal is different: `policy_refusal` means the provider refused
205
+ the content, so a retry needs a rephrased brief or another route. Do not send
206
+ "Continue the same task" unchanged. `provider_error_unexplained` means Pi
207
+ reported exactly `Unknown error (no error details in response)`; read the
208
+ provider/gateway logs before retrying. Harnex does not infer a policy refusal
209
+ when Pi supplies no details.
203
210
 
204
211
  ## Watching a worker
205
212
 
@@ -214,7 +221,12 @@ truncate done 2m03s gpt-5.1 harnex/truncate harnex land tr
214
221
  ```
215
222
 
216
223
  A working lane shows the tool it is running right now (tool names only; harnex
217
- never records tool arguments). `harnex pane --id NAME --lines 40` captures the
224
+ never records tool arguments). `harnex lanes --json` and MCP `lanes` return
225
+ current settlement (`done`/`failed` even before collection), a separate
226
+ `collected` boolean, and process-liveness `live`. `collected: false` means
227
+ `harnex result` still needs to collect the turn; listing does not do that.
228
+ The table keeps `finished, uncollected` for those finished workers.
229
+ `harnex pane --id NAME --lines 40` captures the
218
230
  worker's tmux window, and the window itself is always there to watch.
219
231
 
220
232
  ## Where things live
data/docs/mcp.md CHANGED
@@ -17,8 +17,12 @@ servers when a session starts. `harnex doctor` reports the registrations under
17
17
 
18
18
  ## Tools
19
19
 
20
- Each tool runs the matching CLI command and returns its text: the worker's
21
- reply followed by the `── NAME:` footer.
20
+ Each tool runs the matching CLI command and returns its text: normally the
21
+ worker's reply followed by the `── NAME:` footer. `lanes` returns the
22
+ `harnex lanes` table, with each worker's live activity; a settled worker reads
23
+ "finished, uncollected" until its result is collected. Scripts use
24
+ `harnex lanes --json`, whose `state` is `done`/`failed` for a settled worker
25
+ (never `running`), with separate `collected` and `live` fields.
22
26
 
23
27
  | Tool | Arguments | Does |
24
28
  | --- | --- | --- |
@@ -28,7 +32,7 @@ reply followed by the `── NAME:` footer.
28
32
  | `reply` | `name`, `message`; `model`, `wait`, `cwd`, `wait_minutes` | Follow-up to the same worker, which keeps its context |
29
33
  | `land` | `names` (optional); `cwd` | Merge worktree lanes, remove worktrees and branches |
30
34
  | `discard` | `names`; `cwd` | Drop lanes without merging |
31
- | `lanes` | `all`; `cwd` | Open lanes, what each worker is doing, the next step |
35
+ | `lanes` | `all`; `cwd` | Open lane JSON: settled state, collection, liveness, model and branch |
32
36
 
33
37
  Arguments are forgiving: `prompt`, `brief` or `message` work for `task`;
34
38
  `prompt` or `query` for `question`; `lane` or `id` for `name`. `cwd` defaults
data/docs/pi-rpc.md CHANGED
@@ -107,6 +107,15 @@ Harnex maps Pi events as follows:
107
107
  | `agent_settled` without an authoritative final stop reason | fail closed |
108
108
  | EOF, malformed JSON, or request timeout | disconnect/failure |
109
109
 
110
+ A final Pi `error` with refusal text (`cyber_policy`, content filter/policy,
111
+ "flagged for possible cybersecurity risk") settles as `policy_refusal`.
112
+ A retry needs a rephrased brief or another route, not the same task unchanged.
113
+ The exact `Unknown error (no error details in response)` settles as
114
+ `provider_error_unexplained`: inspect provider/gateway logs before retrying;
115
+ it is not evidence of a refusal. Both override exhausted adapter retries;
116
+ otherwise `provider_capacity` keeps its existing retry-exhaustion behavior.
117
+ These classes survive status, dispatch rows and `watch --owner` events.
118
+
110
119
  A forced send while Pi is busy is translated to a `prompt` with
111
120
  `streamingBehavior="steer"`; ordinary sends wait for the settled prompt state.
112
121
  This avoids sending a plain prompt that current Pi correctly rejects while
@@ -238,7 +238,10 @@ harnex watch --owner --stall-after 25m
238
238
  `session_id`, `repo`, `ts`, `work_state`, plus `outcome_class`,
239
239
  `receipt_path`, `elapsed_s`, `stall`, `reason` where they apply, and a
240
240
  readable `msg`. No heartbeats, so Claude Code's `Monitor` wakes only when
241
- something needs a decision.
241
+ something needs a decision. Provider failures preserve `policy_refusal`
242
+ (rephrase the brief or use another route), `provider_error_unexplained`
243
+ (read provider/gateway logs before retrying), and `provider_capacity`
244
+ (adapter retries exhausted) as distinct outcome classes.
242
245
  - **Restarts:** a restarted stream re-announces `started` for running
243
246
  dispatches and delivers only results it has not delivered before (cursor
244
247
  under the owner's notifications directory). Delivery is not acceptance:
data/guides/06_claude.md CHANGED
@@ -70,7 +70,12 @@ harnex reply word-count "Also add TextKit.char_count(text) with a test."
70
70
  A worker still on its turn takes the message as a follow-up. A finished lane
71
71
  is relaunched on its own Pi session, so the worker keeps everything it knew.
72
72
  After a provider-capacity failure, reply "Continue the same task from where
73
- you stopped", optionally with `--model OTHER`.
73
+ you stopped", optionally with `--model OTHER`. Do not do that for
74
+ `policy_refusal`: the provider refused the content, so retry with a rephrased
75
+ brief or another route. For `provider_error_unexplained`, read the
76
+ provider/gateway logs before retrying. `harnex lanes --json` and MCP `lanes`
77
+ separate settled `state` (`done`/`failed`) from `collected` and process `live`;
78
+ uncollected results are still available through `result`.
74
79
 
75
80
  ## Rules For Claude Code
76
81
 
@@ -216,6 +216,8 @@ module Harnex
216
216
  @out.puts answer unless answer.to_s.empty?
217
217
  reason = turn["last_error"] || turn["outcome_class"] || turn["status"] || result["error"] || "no result"
218
218
  @err.puts("harnex ask: #{result['id'] || 'the worker'} did not answer (#{reason})")
219
+ hint = Lanes.provider_failure_hint("outcome" => turn["outcome_class"])
220
+ @err.puts("next: #{hint}") if hint
219
221
  end
220
222
  exit_code
221
223
  end
@@ -34,7 +34,7 @@ module Harnex
34
34
  Lanes.sweep_merged(Lanes.main_root(Dir.pwd))
35
35
  lanes = selected
36
36
  if @argv.include?("--json")
37
- @out.puts JSON.generate(lanes.map { |lane| lane.merge("live" => Lanes.live?(lane)) })
37
+ @out.puts JSON.generate(lanes.map { |lane| Lanes.snapshot(lane) })
38
38
  elsif lanes.empty?
39
39
  @out.puts "no lanes#{@argv.include?('--all') ? '' : ' in this repo'} (start one with `harnex delegate`)"
40
40
  else
@@ -51,7 +51,8 @@ module Harnex
51
51
  Worker outcome classes (dispatch row outcome.class):
52
52
  completed completed_with_proof completed_with_activity
53
53
  proof completed_no_activity* report_missing report_invalid report_rejected
54
- process task_failed* provider_capacity* boot_failure* permission_prompt*
54
+ provider provider_capacity* policy_refusal* provider_error_unexplained*
55
+ process task_failed* boot_failure* permission_prompt*
55
56
  timeout* process_failed* stopped_by_caller
56
57
  fallback unknown
57
58
  The budget is a ledger, not a breaker: enforcement stays with the caller.
data/lib/harnex/lanes.rb CHANGED
@@ -11,8 +11,9 @@ module Harnex
11
11
  # harnex/<name>. A record under STATE_DIR/lanes lets `result`, `reply`,
12
12
  # `land` and `discard` find a lane by name from anywhere in its repo.
13
13
  #
14
- # Record states: running (a turn is out and nobody has collected it),
15
- # done, failed, landed, discarded.
14
+ # Internal record states: running (a turn is out and uncollected), done,
15
+ # failed, landed, discarded. Machine listings project the worker's settled
16
+ # state independently, with `collected` indicating the record's boundary.
16
17
  module Lanes
17
18
  NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,47}\z/
18
19
  BRANCH_PREFIX = "harnex/".freeze
@@ -154,6 +155,57 @@ module Harnex
154
155
  !registry.nil? && Harnex.alive_pid?(registry["pid"])
155
156
  end
156
157
 
158
+ # #93: listing is read-only, not result collection. The record's running
159
+ # state still drives pending/result and the table's "finished, uncollected".
160
+ # Fence evidence to this launch and follow-up so a reused name cannot pick
161
+ # up an earlier turn. Settlement can precede process exit (Pi at prompt).
162
+ def snapshot(lane)
163
+ live = live?(lane)
164
+ view = lane.merge("live" => live, "collected" => lane["state"] != "running")
165
+ return view unless lane["state"] == "running"
166
+
167
+ %w[outcome last_error settled_at last_turn].each { |key| view.delete(key) }
168
+ event = events(lane).reverse.find do |row|
169
+ row.is_a?(Hash) && %w[task_complete task_failed work_turn agent_start].include?(row["type"]) &&
170
+ after?(row["ts"], lane["launched_at"]) &&
171
+ (!lane["after_seq"] || row["seq"].to_i > lane["after_seq"].to_i)
172
+ end
173
+ if event && %w[task_complete task_failed].include?(event["type"])
174
+ failed = event["type"] == "task_failed" ||
175
+ (!event["status"].to_s.empty? && !%w[completed success succeeded].include?(event["status"]))
176
+ view.merge!("state" => failed ? "failed" : "done", "outcome" => event["outcome_class"] || event["status"],
177
+ "settled_at" => event["ts"])
178
+ view["last_error"] = event["message"] || event["error"] if failed
179
+ elsif !live
180
+ # Process failures may exit without a task event. The canonical end
181
+ # row supplies their class; ignore old rows from a previous launch.
182
+ terminal = TerminalStatus.resolve(id: lane["name"], repo_root: lane["run_root"])
183
+ if terminal && terminal["terminal"] && after?(terminal["ended_at"], lane["launched_at"])
184
+ view.merge!("state" => terminal["work_state"] == "completed" ? "done" : "failed",
185
+ "outcome" => terminal["outcome_class"], "settled_at" => terminal["ended_at"])
186
+ elsif (exit_status = fresh_exit_status(lane))
187
+ done = exit_status["task_complete"] == true ||
188
+ (exit_status["task_failed"] != true && exit_status["exit_code"] == 0)
189
+ view.merge!("state" => done ? "done" : "failed", "outcome" => exit_status["outcome_class"],
190
+ "settled_at" => exit_status["exited_at"])
191
+ end
192
+ end
193
+ view
194
+ end
195
+
196
+ def fresh_exit_status(lane)
197
+ path = Harnex.exit_status_path(lane["run_root"], lane["name"])
198
+ data = JSON.parse(File.read(path))
199
+ return nil unless data.is_a?(Hash)
200
+
201
+ # Exit timestamps have second precision; mtime also fences same-second
202
+ # relaunches so an earlier dispatch's leftover file cannot settle one.
203
+ since = Time.iso8601(lane["launched_at"].to_s)
204
+ data if File.mtime(path) >= since && after?(data["exited_at"], since.iso8601)
205
+ rescue Errno::ENOENT, JSON::ParserError, ArgumentError
206
+ nil
207
+ end
208
+
157
209
  # The live worker's activity snapshot (see ActivityTracker), or nil.
158
210
  def activity(lane)
159
211
  registry = Harnex.read_registry(lane["run_root"], lane["name"])
@@ -678,6 +730,8 @@ module Harnex
678
730
  def details(lane, exit_code)
679
731
  name = lane["name"]
680
732
  return ["keep waiting: harnex result #{name}"] if exit_code == Waiter::DONE_EXIT_TIMEOUT
733
+ provider_hint = provider_failure_hint(lane)
734
+ return ["next: #{provider_hint}"] if lane["kind"] == "ask" && !exit_code.zero? && provider_hint
681
735
  return ["more: harnex reply #{name} \"...\""] if lane["kind"] == "ask"
682
736
 
683
737
  out = []
@@ -697,6 +751,8 @@ module Harnex
697
751
  else
698
752
  out << "worked in place in #{lane['cwd']}; more work: `harnex reply #{name} \"...\"`"
699
753
  end
754
+ elsif provider_hint
755
+ out << "next: #{provider_hint}#{lane['branch'] ? "; or `harnex discard #{name}`" : ''}"
700
756
  else
701
757
  retry_model = model_problem?(lane) ? other_model(lane) : nil
702
758
  out << "next: `harnex reply #{name} \"Continue the same task from where you stopped\"" \
@@ -707,6 +763,15 @@ module Harnex
707
763
  out
708
764
  end
709
765
 
766
+ def provider_failure_hint(lane)
767
+ case lane["outcome"]
768
+ when "policy_refusal"
769
+ "provider refused the content; a retry needs a rephrased brief or another route"
770
+ when "provider_error_unexplained"
771
+ "read the provider/gateway logs before retrying; the provider returned no error details"
772
+ end
773
+ end
774
+
710
775
  def commit_count(lane)
711
776
  count, _err, ok = git(lane["worktree"] || lane["cwd"], "rev-list", "--count", "#{lane['base_sha']}..HEAD")
712
777
  ok ? count.to_i : 0
@@ -23,7 +23,8 @@ module Harnex
23
23
  # are not fatal unless a caller names them (#57). provider_capacity was
24
24
  # task_failed before #82 and stays fatal; pass fatal classes to exempt it.
25
25
  DEFAULT_FATAL_CLASSES = %w[
26
- completed_no_activity task_failed provider_capacity boot_failure permission_prompt timeout process_failed
26
+ completed_no_activity task_failed provider_capacity policy_refusal provider_error_unexplained
27
+ boot_failure permission_prompt timeout process_failed
27
28
  ].freeze
28
29
 
29
30
  module_function
@@ -54,7 +54,7 @@ module Harnex
54
54
  # rate limits, not the work. Structured signal only, never text.
55
55
  OUTCOME_CLASSES = (
56
56
  %w[completed_with_proof completed_with_activity] + PROOF_REJECTION_CLASSES +
57
- %w[task_failed provider_capacity] + PROCESS_OUTCOME_CLASSES + %w[unknown]
57
+ %w[task_failed provider_capacity policy_refusal provider_error_unexplained] + PROCESS_OUTCOME_CLASSES + %w[unknown]
58
58
  ).freeze
59
59
  # Pi tools whose structured results carry an integer exit code.
60
60
  PI_COMMAND_TOOLS = %w[bash].freeze
@@ -1263,6 +1263,10 @@ module Harnex
1263
1263
  nil
1264
1264
  end
1265
1265
 
1266
+ # #93: these are Pi's structured errorMessage/finalError, not transcript
1267
+ # text. Explicit provider errors take precedence over exhausted retries.
1268
+ PI_POLICY_REFUSAL = /\bcyber_policy\b|content[\s_-]*(?:filter|policy)|flagged for possible cybersecurity risk/i
1269
+
1266
1270
  def settle_pi_task!
1267
1271
  return if @stop_requested || @pi_transport_failed
1268
1272
 
@@ -1273,8 +1277,16 @@ module Harnex
1273
1277
  when "error", "aborted", "length"
1274
1278
  error = @pi_last_assistant_error || @pi_last_retry_error
1275
1279
  error = "Pi agent settled with stopReason=#{reason}" if error.to_s.empty?
1276
- capacity = reason == "error" && !@pi_last_retry_error.to_s.empty?
1277
- mark_task_failed(status: reason, error: error, outcome_class: capacity ? "provider_capacity" : nil)
1280
+ outcome_class = if reason == "error"
1281
+ if [error, @pi_last_retry_error].any? { |text| text.to_s.match?(PI_POLICY_REFUSAL) }
1282
+ "policy_refusal"
1283
+ elsif error == "Unknown error (no error details in response)"
1284
+ "provider_error_unexplained"
1285
+ elsif !@pi_last_retry_error.to_s.empty?
1286
+ "provider_capacity"
1287
+ end
1288
+ end
1289
+ mark_task_failed(status: reason, error: error, outcome_class: outcome_class)
1278
1290
  when nil, ""
1279
1291
  mark_task_failed(
1280
1292
  status: "missing_final_message",
@@ -2549,6 +2561,7 @@ module Harnex
2549
2561
  # Status/source derive from the proof verdict, not the refined label.
2550
2562
  proof_failure = %w[
2551
2563
  completed_no_activity report_missing report_invalid report_rejected task_failed provider_capacity
2564
+ policy_refusal provider_error_unexplained
2552
2565
  ].include?(base_class)
2553
2566
  harness_receipt = artifact_payload&.dig("receipt", "author") == "harnex"
2554
2567
  status = receipt_outcome["status"]
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.30.0"
2
+ VERSION = "0.30.1"
3
3
  RELEASE_DATE = "2026-10-25"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
14
14
  the result back in one call: lanes in git worktrees, follow-ups that keep context,