@gotcos/glasses-server 6.36.10 → 6.36.11

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 CHANGED
@@ -1,5 +1,60 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.36.11
4
+ - **Fences now record WHY, so the population can be measured before anything resolves
5
+ automatically.** Two plans designed an automatic fence resolver and both were rejected —
6
+ the second because there has never been a single fence on the machine to look at. If
7
+ `timeout` dominates, the child had the full 21-minute budget to run tool calls before
8
+ SIGKILL and re-delivery would re-execute them, so no automatic clear is ever safe. That
9
+ question was unanswerable and now is not.
10
+ - **`reaped` is reported by the adapter, never derived.** A signal-killed child reports
11
+ `code === null` and the handlers only assign `exitCode` for a numeric code — so deriving
12
+ "was it reaped" from `exitCode` reports NEVER REAPED for the dominant timeout shape
13
+ (SIGTERM, then SIGKILL), which is exactly backwards for the decision this data informs.
14
+ The first cut of this change did derive it. `AttachedTurnFailureResult` now carries
15
+ `reaped`, true from every settle reached via `close`/`error` and false only from the
16
+ force-settle that fires when `close` never arrived.
17
+ - **An unreadable adapter result records nothing, not zeroes.** Reading `{}` and writing
18
+ `exitCode: null, childReaped: false` states two facts about a child nothing is known
19
+ about, indistinguishable on disk from a confirmed-unreaped timeout — corrupting the one
20
+ discriminator this evidence exists to establish.
21
+ - **`fenceSite` says which site fired.** `adapterReason` cannot substitute: the catch site
22
+ inherits whatever the adapter last reported, so a route crash AFTER a clean delivery
23
+ records `ok` — the strongest possible reason NOT to re-deliver, which would otherwise
24
+ read as "nothing went wrong".
25
+ - **One resolved reason for the record and the log.** The record said `unreadable_result`
26
+ while the breadcrumb said `unknown`, so an operator grepping for the sentinel found
27
+ nothing. This is the same contradiction 6.36.10 fixed at the other fence site,
28
+ re-committed one release later in the same handler; both now read one value.
29
+ - **A release no longer destroys the evidence.** `releaseFence` deletes the row, and the
30
+ realistic first-fence sequence is: fence lands, Control's card appears, it is released,
31
+ the distribution is gone. The release breadcrumb now carries the whole record.
32
+ - **Spawn identity as `{pid, startMs}` PAIRS.** `recordedPids` held bare pids and the
33
+ measured start was discarded; a pid alone cannot be told apart from a recycled one.
34
+ The evidence type is narrowed to the six adapter fields so the spread at the fence
35
+ sites cannot clobber the fence's own identity — `Partial<FenceEvidence>` permitted
36
+ `provider`, and the adapter result carries one that would write null and fail
37
+ `isFenceRecord` on the next read, silently un-enforcing the fence.
38
+ - **All fields OPTIONAL and NOT in `isFenceRecord`.** That predicate is cast-based, so a
39
+ required field would type as present while being undefined at runtime; extending it
40
+ would reclassify existing rows as unrecognised and silently un-enforce them.
41
+ - **DISK ONLY.** Nothing reaches the wire; asserted against the real `/fences` body and
42
+ the release preview.
43
+ - **No behaviour change.** Nothing clears, nothing refuses differently. Upgrade, downgrade
44
+ and the COS Control card were all verified unaffected.
45
+ - **Coverage, stated honestly.** The adapter's `reaped` contract is tested at the adapter,
46
+ driving a real `close(null)` — a route test could not cover it, because the route
47
+ fixtures supply `reaped` themselves and would pass with the adapter gutted. Mutation
48
+ results: caught — derived-from-exitCode, adapter stops reporting reaped, startMs zeroed,
49
+ empty spawn list, missing fenceSite, unrecorded adapterReason, wire leak. **Survived, and
50
+ therefore unverified: `stderrClass` is written but asserted nowhere, and `fail()`'s
51
+ `reaped: false` default on the `not_attempted` paths (which never fence).** The
52
+ `route_error` fence site remains reachable by no test.
53
+ - **Known gaps, not fixed here:** `stderrClass` appears in the breadcrumbs but on a
54
+ default install (`COS_THREAD_FENCE_DURABLE` unset) nothing is written to disk at all;
55
+ and reading the distribution means reading `thread-fences.json` or the server log —
56
+ there is no UI for it.
57
+
3
58
  ## 6.36.10
4
59
  - **A fenced thread had no exit and left no trace.** An ambiguous delivery fences the
5
60
  target so a prompt cannot be double-delivered into a real conversation — that is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.10",
3
+ "version": "6.36.11",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1063,9 +1063,29 @@ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots):
1063
1063
  const firstPrompt = await firstClaudeUserTitle(found)
1064
1064
  const title = peek.customTitle ?? firstPrompt ?? row.display_label
1065
1065
  const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
1066
+ // THE LIVE ROW'S `modified` IS A HEARTBEAT, NOT AN MTIME, AND IT GOES STALE.
1067
+ //
1068
+ // `liveClaudeRows` builds it from the peer registry's `lastActiveAt` (routes/
1069
+ // agent-sessions.ts:257). That value tracks the registry record, not the
1070
+ // transcript, so a session that is actively writing keeps reporting whenever the
1071
+ // registry last moved. Measured 2026-08-18 on three live sessions: the wire said
1072
+ // 55.3m / 407.7m / 435.0m while the transcript had been written 0.1m / 0.2m /
1073
+ // 5.1m earlier — under-reporting a live session by up to 7.2 hours.
1074
+ //
1075
+ // We are already holding the resolved transcript path and have already read it
1076
+ // twice, so the true mtime costs one stat. A failed stat keeps the heartbeat:
1077
+ // worse, but no worse than today.
1078
+ //
1079
+ // THAT FALLBACK IS DEFENSIVE AND UNREACHED BY THE SUITE. `if (!found) return row`
1080
+ // above already catches the missing-file case, so reaching `: row.modified` needs a
1081
+ // stat to fail on a file that was just resolved. A mutation of it survives; that is
1082
+ // recorded rather than papered over.
1083
+ const st = await fileStat(found)
1084
+ const modified = st?.isFile ? isoFromMtime(st.mtimeMs) : row.modified
1066
1085
  return {
1067
1086
  ...row,
1068
1087
  session_id: fullId,
1088
+ modified,
1069
1089
  display_label: title || row.display_label || 'Claude session',
1070
1090
  ...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
1071
1091
  }
@@ -194,6 +194,18 @@ export interface AttachedTurnFailureResult {
194
194
  */
195
195
  detail: string | null
196
196
  exitCode: number | null
197
+ /**
198
+ * Did `close`/`exit` actually fire, i.e. did the kernel reap the child?
199
+ *
200
+ * NOT DERIVABLE FROM `exitCode`. A child killed by a signal reports
201
+ * `code === null`, and the handlers below only assign `exitCode` for a numeric
202
+ * code — so the dominant timeout shape (SIGTERM, then SIGKILL) is reaped while
203
+ * leaving `exitCode` null. Deriving reaping from the code therefore reports
204
+ * "never reaped" for exactly the case a reader most needs to identify.
205
+ * Only the force-settle path, which fires when `close` never arrived, is
206
+ * genuinely unreaped.
207
+ */
208
+ reaped: boolean
197
209
  stderrClass: AttachedStderrClass
198
210
  durationMs: number
199
211
  }
@@ -745,6 +757,10 @@ function fail(
745
757
  nativeThreadId: null,
746
758
  returnedNativeId: null,
747
759
  delivery,
760
+ // Conservative default: NOT OBSERVED reaped. Most `fail()` callers are
761
+ // `not_attempted` paths where no child exists, and the ones that do have a
762
+ // child override this from `settleFailure`.
763
+ reaped: false,
748
764
  reason,
749
765
  detail: null,
750
766
  exitCode: null,
@@ -1051,6 +1067,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1051
1067
  nativeThreadId,
1052
1068
  returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
1053
1069
  exitCode,
1070
+ // Every settle but the force-settle below is reached from `finishTerminal`,
1071
+ // which only runs from the `close`/`error` handlers — so the child was
1072
+ // reaped. The one exception overrides this explicitly.
1073
+ reaped: true,
1054
1074
  stderrClass: classifyStderr(stderrSample),
1055
1075
  durationMs: duration(),
1056
1076
  ...over,
@@ -1193,7 +1213,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
1193
1213
  // A child that survived SIGKILL cannot be reached from here, and
1194
1214
  // blocking forever would wedge the coordinator and any Control drain
1195
1215
  // behind it.
1196
- settleFailure('timeout', { detail: 'unreaped' })
1216
+ settleFailure('timeout', { detail: 'unreaped', reaped: false })
1197
1217
  }, FORCE_SETTLE_MS)
1198
1218
  }, KILL_GRACE_MS)
1199
1219
  }, timeoutMs)
@@ -34,6 +34,69 @@ export interface FenceRecord {
34
34
  turnId: string
35
35
  bindingId: string | null
36
36
  fencedAt: number
37
+
38
+ // ── EVIDENCE (all optional, all DISK-ONLY) ────────────────
39
+ //
40
+ // WHY THIS EXISTS. Two plans designed an automatic fence resolver and both were
41
+ // rejected, the second because we have never observed a single fence. The
42
+ // distribution decides whether any automatic path is safe: if `timeout`
43
+ // dominates, the child had the full 21-minute budget to run tool calls before
44
+ // SIGKILL and re-delivery re-executes them, so no automatic clear is ever safe.
45
+ // If the child exited on its own with a known code, the footing is much better.
46
+ //
47
+ // EVERY FIELD IS OPTIONAL, and deliberately NOT added to `isFenceRecord`. That
48
+ // predicate is cast-based (`r is FenceRecord`), so a REQUIRED field would type as
49
+ // present while being undefined at runtime and tsc could not catch it; and
50
+ // extending the validator would reclassify existing rows as unrecognised, routing
51
+ // them into the preserved-but-inert pile and silently un-enforcing real fences.
52
+ //
53
+ // NONE OF THIS REACHES THE WIRE. The router contract carries no pid and no native
54
+ // thread id; `listFences()` already omits `bindingId` as precedent.
55
+
56
+ /**
57
+ * The adapter's own verdict.
58
+ *
59
+ * REACHABILITY IS DECIDED BY `delivery === 'ambiguous'`, i.e. the stdin-write
60
+ * boundary — only a turn whose prompt bytes reached the child can fence. The
61
+ * reachable set is therefore NOT the full `AttachedTurnFailure` enum:
62
+ * timeout, provider_exit_nonzero, no_native_id_returned, native_id_mismatch,
63
+ * child_stdio_unavailable (detail `write_failed`), spawn_failed (ONLY via
64
+ * detail `child_error`; its other forms are `not_attempted` and never fence),
65
+ * plus two this route synthesises — `ok` (the adapter reported success with a
66
+ * non-delivered state, a contradiction) and `unreadable_result`.
67
+ * `adapter_internal_error` can NEVER fence: every emission site is
68
+ * `not_attempted` or guarded. `route_error` comes from the catch site.
69
+ */
70
+ adapterReason?: string
71
+ /**
72
+ * WHICH fence site fired.
73
+ *
74
+ * `adapterReason` cannot substitute. The catch site inherits whatever the
75
+ * adapter last reported, so a route crash AFTER a clean delivery records
76
+ * `ok` — the single strongest reason NOT to re-deliver, which would read as
77
+ * "nothing went wrong". Only this field separates them.
78
+ */
79
+ fenceSite?: 'ambiguous' | 'route_error'
80
+ /** Bounded self-authored discriminator, e.g. `unreaped`. Never provider output. */
81
+ adapterDetail?: string | null
82
+ exitCode?: number | null
83
+ /**
84
+ * Reported BY THE ADAPTER, never derived from `exitCode`.
85
+ *
86
+ * A signal-killed child reports `code === null`, and the adapter only assigns
87
+ * `exitCode` for a numeric code — so deriving this would record "never reaped"
88
+ * for the dominant timeout shape (SIGTERM then SIGKILL), which is the exact
89
+ * case this evidence exists to identify. Undefined when the adapter result was
90
+ * unreadable: absent, never a fabricated `false`.
91
+ */
92
+ childReaped?: boolean
93
+ stderrClass?: string
94
+ /** How long the turn ran before it failed. A timeout is ~21 minutes of tool calls. */
95
+ durationMs?: number
96
+ /** Every child COS spawned for this turn, with its MEASURED start. Both are needed:
97
+ * a pid alone cannot be distinguished from a recycled one. An EMPTY list means no
98
+ * child was ever spawned, which no resolver may ever read as "nothing landed". */
99
+ spawns?: Array<{ pid: number; startMs: number }>
37
100
  }
38
101
 
39
102
  export function fencePath(): string {
@@ -839,6 +839,17 @@ export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
839
839
  /** What a fence records about the turn that set it. */
840
840
  export interface FenceEvidence {
841
841
  provider: string
842
+ /** The adapter's verdict, or 'route_error' at the catch site. DISK-ONLY. */
843
+ adapterReason?: string
844
+ /** WHICH site fenced. `adapterReason` cannot substitute: the catch site inherits
845
+ * whatever the adapter reported, so a fence can read 'ok' there. DISK-ONLY. */
846
+ fenceSite?: 'ambiguous' | 'route_error'
847
+ adapterDetail?: string | null
848
+ exitCode?: number | null
849
+ childReaped?: boolean
850
+ stderrClass?: string
851
+ durationMs?: number
852
+ spawns?: Array<{ pid: number; startMs: number }>
842
853
  /** The head BEFORE the ambiguous turn. Null ONLY when the failure happened
843
854
  * before the head was read. */
844
855
  headBefore: string | null
@@ -961,6 +972,14 @@ class TargetGuard {
961
972
  turnId: evidence.turnId,
962
973
  bindingId: evidence.bindingId,
963
974
  fencedAt: evidence.now,
975
+ adapterReason: evidence.adapterReason,
976
+ fenceSite: evidence.fenceSite,
977
+ adapterDetail: evidence.adapterDetail,
978
+ exitCode: evidence.exitCode,
979
+ childReaped: evidence.childReaped,
980
+ stderrClass: evidence.stderrClass,
981
+ durationMs: evidence.durationMs,
982
+ spawns: evidence.spawns,
964
983
  })
965
984
  this.persistFences([...this.fences.values()])
966
985
  }
@@ -1389,7 +1408,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1389
1408
  res.status(status).json({ released: false, reason: outcome.reason })
1390
1409
  return
1391
1410
  }
1392
- console.warn(`[agent-session-bindings] fence RELEASED by operator target=${target} provider=${outcome.row.provider} fencedAt=${outcome.row.fencedAt}`)
1411
+ // THE EVIDENCE DIES WITH THE ROW. `releaseFence` deletes it, and the realistic
1412
+ // sequence is: first fence ever lands -> Control's card appears -> it is
1413
+ // released -> the distribution this evidence exists to collect is gone. So the
1414
+ // release line carries the whole record, not just its identity.
1415
+ const ev = outcome.row
1416
+ console.warn(`[agent-session-bindings] fence RELEASED by operator target=${target} provider=${ev.provider} fencedAt=${ev.fencedAt} fenceSite=${ev.fenceSite ?? 'unknown'} adapterReason=${ev.adapterReason ?? 'unknown'} detail=${ev.adapterDetail ?? 'none'} exitCode=${ev.exitCode ?? 'null'} childReaped=${ev.childReaped ?? 'unknown'} stderrClass=${ev.stderrClass ?? 'none'} durationMs=${ev.durationMs ?? 'unknown'} spawnCount=${ev.spawns?.length ?? 0}`)
1393
1417
  res.json({ released: true, target, provider: outcome.row.provider })
1394
1418
  })
1395
1419
 
@@ -1809,7 +1833,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
1809
1833
  let fenceProvider = ''
1810
1834
  let preTurnHeadDigest: string | null = null
1811
1835
  /** Children the adapter reported, released in the finally. */
1812
- const recordedPids: number[] = []
1836
+ // PAIRS, not bare pids. A pid alone cannot be told apart from a recycled one,
1837
+ // and `startMs` is measured in the onSpawn closure and was previously discarded.
1838
+ const recordedPids: Array<{ pid: number; startMs: number }> = []
1839
+ // Hoisted so BOTH fence sites can record what the adapter actually reported.
1840
+ // `result` is scoped inside the try; the ambiguous site sits after the catch.
1841
+ // NARROW ON PURPOSE. `Partial<FenceEvidence>` would permit provider/headBefore/
1842
+ // turnId/bindingId/now, and the spread sits AFTER them at both fence sites — so
1843
+ // a future field could silently overwrite the fence's identity. The adapter
1844
+ // result literally carries a `provider`, which would write null and fail
1845
+ // `isFenceRecord` on the next read, silently un-enforcing the fence.
1846
+ let adapterEvidence: Partial<Pick<FenceEvidence,
1847
+ 'adapterReason' | 'adapterDetail' | 'exitCode' | 'childReaped' | 'stderrClass' | 'durationMs'>> = {}
1813
1848
  let pinnedBindingId: string | null = null
1814
1849
  let requestNow = 0
1815
1850
  /**
@@ -2095,10 +2130,35 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2095
2130
  // before the prompt rather than deliver a turn that poisons the next
2096
2131
  // occupancy check.
2097
2132
  if (outcome !== 'recorded') return false
2098
- recordedPids.push(pid)
2133
+ recordedPids.push({ pid, startMs })
2099
2134
  return true
2100
2135
  },
2101
2136
  })
2137
+ // Read the adapter's OWN account before it goes out of scope. This is the
2138
+ // whole point of the evidence work: `classifyDelivery` collapses six distinct
2139
+ // failures into one word, and the difference between them is what decides
2140
+ // whether an automatic resolver could ever be safe. A 21-minute `timeout`
2141
+ // means the child ran tool calls; a `provider_exit_nonzero` with a code means
2142
+ // it exited on its own. Defensive reads — the adapter is injected in tests.
2143
+ // AN UNREADABLE RESULT RECORDS NOTHING, not zeroes. Reading `{}` and
2144
+ // deriving `exitCode: null, childReaped: false` states two facts about a
2145
+ // child nothing is known about, and writes them indistinguishably from a
2146
+ // confirmed-unreaped timeout — corrupting the one discriminator this
2147
+ // evidence exists to establish.
2148
+ const r = (result !== null && typeof result === 'object')
2149
+ ? result as Record<string, unknown>
2150
+ : null
2151
+ adapterEvidence = r === null ? {} : {
2152
+ adapterReason: typeof r.reason === 'string' ? r.reason : (r.ok === true ? 'ok' : undefined),
2153
+ adapterDetail: typeof r.detail === 'string' ? r.detail : null,
2154
+ exitCode: typeof r.exitCode === 'number' ? r.exitCode : null,
2155
+ // FROM THE ADAPTER, never derived from exitCode. A signal-killed child
2156
+ // reports `code === null`, so deriving it would report "never reaped" for
2157
+ // the dominant timeout shape — backwards for the decision this informs.
2158
+ childReaped: typeof r.reaped === 'boolean' ? r.reaped : undefined,
2159
+ stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
2160
+ durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
2161
+ }
2102
2162
  delivery = classifyDelivery(result)
2103
2163
  } catch (error) {
2104
2164
  console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
@@ -2116,18 +2176,30 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2116
2176
  // Fenced under its own reason, not this turn's: `delivery_ambiguous`
2117
2177
  // describes what happened to THIS request, while a later caller needs to
2118
2178
  // be told the thread is shut and why it must be inspected first.
2179
+ // ONE resolved value for the record AND the log. The previous release
2180
+ // fixed exactly this contradiction at the other fence site and this one
2181
+ // re-committed it: the record said `unreadable_result` while the log said
2182
+ // `unknown`, so an operator grepping for the sentinel found nothing.
2183
+ const recordedReason = adapterEvidence.adapterReason ?? 'unreadable_result'
2119
2184
  guard.fence(key, 'native_target_fenced', {
2120
2185
  provider: binding.provider,
2121
2186
  headBefore: head.digest,
2122
2187
  turnId,
2123
2188
  bindingId,
2124
2189
  now: Date.now(),
2190
+ ...adapterEvidence,
2191
+ // NEVER blank. An unreadable adapter result and "nobody recorded it" are
2192
+ // different facts and a missing field cannot tell them apart -- which is
2193
+ // the whole reason this evidence exists.
2194
+ adapterReason: recordedReason,
2195
+ fenceSite: 'ambiguous',
2196
+ spawns: [...recordedPids],
2125
2197
  })
2126
2198
  // A fence shuts a thread until a human acts, and until now it wrote NO log
2127
2199
  // line at either site — so a fenced thread was discoverable only by trying
2128
2200
  // to use it (Miles, 2026-08-18). Never log `key`: it embeds the private
2129
2201
  // native thread id, which this router does not emit anywhere.
2130
- console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest}`)
2202
+ console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=${recordedReason} detail=${adapterEvidence.adapterDetail ?? 'none'} exitCode=${adapterEvidence.exitCode ?? 'null'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} stderrClass=${adapterEvidence.stderrClass ?? 'none'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
2131
2203
  return reportAmbiguous()
2132
2204
  }
2133
2205
 
@@ -2164,20 +2236,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2164
2236
  turnId,
2165
2237
  bindingId: null,
2166
2238
  now: Date.now(),
2239
+ // A bug in THIS route, not a provider outcome. Named rather than left
2240
+ // blank so a later reader can tell "the adapter reported nothing" apart
2241
+ // from "nobody recorded it". Any adapter evidence captured before the
2242
+ // throw is still carried.
2243
+ ...adapterEvidence,
2244
+ adapterReason: adapterEvidence.adapterReason ?? 'route_error',
2245
+ fenceSite: 'route_error',
2246
+ spawns: [...recordedPids],
2167
2247
  })
2168
2248
  // `head` is scoped to the try, so `preTurnHeadDigest` is hoisted to the
2169
2249
  // handler specifically to reach this site. It is null ONLY when the throw
2170
2250
  // happened before the head was read. An earlier version of this line
2171
2251
  // hardcoded `unavailable` and so contradicted the record it had just
2172
2252
  // written — an operator would read "no baseline" off a fence that has one.
2173
- console.warn(`[agent-session-bindings] fence set site=route_error target=${opaqueRevision(claimedKey)} turnId=${turnId} headBefore=${preTurnHeadDigest ?? 'unavailable'}`)
2253
+ console.warn(`[agent-session-bindings] fence set site=route_error target=${opaqueRevision(claimedKey)} turnId=${turnId} headBefore=${preTurnHeadDigest ?? 'unavailable'} adapterReason=${adapterEvidence.adapterReason ?? 'route_error'} detail=${adapterEvidence.adapterDetail ?? 'none'} exitCode=${adapterEvidence.exitCode ?? 'null'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} stderrClass=${adapterEvidence.stderrClass ?? 'none'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
2174
2254
  }
2175
2255
  reportAmbiguous()
2176
2256
  } else {
2177
2257
  refuseTurn('turn_failed')
2178
2258
  }
2179
2259
  } finally {
2180
- for (const pid of recordedPids) {
2260
+ for (const { pid } of recordedPids) {
2181
2261
  try {
2182
2262
  ownership.release(pid)
2183
2263
  } catch (error) {