@humwick/runner 0.130.5 → 0.130.6
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/agent-bin/humwick +2 -2
- package/agent-bin/humwick-browser +4 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +1 -1
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +2 -2
- package/package.json +7 -10
- package/plugins/browser/skills/playwright-cli/SKILL.md +8 -6
- package/plugins/claude/.claude-plugin/plugin.json +3 -3
- package/plugins/claude/claude-statusline-probe.cjs +26 -16
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +47 -17
- package/plugins/claude/hooks/gate-run-guard.sh +3 -3
- package/plugins/claude/hooks/gate-run-guard.ts +5 -5
- package/plugins/claude/hooks/kill-guard.sh +4 -4
- package/plugins/claude/hooks/kill-guard.ts +1 -1
- package/plugins/claude/hooks/permission-request.sh +1 -1
- package/plugins/claude/hooks/post-compact.sh +1 -1
- package/plugins/claude/hooks/post-tool-use.sh +3 -3
- package/plugins/claude/hooks/pre-compact.sh +2 -2
- package/plugins/claude/hooks/read-only-bash-guard.sh +4 -4
- package/plugins/claude/hooks/relay-status.sh +32 -32
- package/plugins/claude/hooks/session-end.sh +2 -2
- package/plugins/claude/hooks/session-start.sh +3 -3
- package/plugins/claude/hooks/shell-trust.ts +1 -1
- package/plugins/claude/hooks/stop-failure.sh +2 -2
- package/plugins/claude/hooks/stop.sh +6 -6
- package/plugins/claude/hooks/subagent-skill-policy.cjs +8 -6
- package/plugins/claude/hooks/subagent-stop.sh +3 -3
- package/plugins/claude/hooks/user-prompt-submit.sh +1 -1
- package/plugins/claude/hooks/wait-loop-guard.ts +3 -3
- package/plugins/claude/monitors/monitors.json +2 -2
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +96 -62
- package/plugins/claude/monitors/relay-monitor.ts +5 -5
- package/plugins/claude/skills/label/SKILL.md +3 -3
- package/plugins/claude/skills/message/SKILL.md +5 -5
- package/plugins/claude/skills/react/SKILL.md +1 -1
- package/plugins/claude/skills/read-message/SKILL.md +4 -4
- package/plugins/claude/skills/reply/SKILL.md +5 -5
- package/plugins/claude/skills/send-claimable/SKILL.md +5 -5
- package/plugins/claude/skills/slice-first/SKILL.md +5 -3
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +30 -29
- package/plugins/claude/skills/status/SKILL.md +1 -1
- package/plugins/claude/skills/tags/SKILL.md +5 -5
- package/plugins/claude/skills/task/SKILL.md +10 -10
- package/plugins/claude-output-style/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-output-style/output-styles/{agent-relay.md → humwick.md} +1 -1
- package/plugins/codex/skills/guide/SKILL.md +1 -1
- package/plugins/codex/skills/label/SKILL.md +3 -3
- package/plugins/codex/skills/message/SKILL.md +5 -5
- package/plugins/codex/skills/react/SKILL.md +1 -1
- package/plugins/codex/skills/read-message/SKILL.md +4 -4
- package/plugins/codex/skills/reply/SKILL.md +5 -5
- package/plugins/codex/skills/send-claimable/SKILL.md +5 -5
- package/plugins/codex/skills/slice-first/SKILL.md +5 -3
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +30 -29
- package/plugins/codex/skills/status/SKILL.md +1 -1
- package/plugins/codex/skills/tags/SKILL.md +5 -5
- package/plugins/codex/skills/task/SKILL.md +11 -10
- package/src/adapter.ts +95 -92
- package/src/adapters/claude-api.ts +8 -11
- package/src/adapters/claude-context-command.ts +1 -1
- package/src/adapters/claude-credentials.ts +7 -3
- package/src/adapters/claude-delivery-lanes.ts +2 -2
- package/src/adapters/claude-delivery.ts +16 -16
- package/src/adapters/claude-effort.ts +2 -2
- package/src/adapters/claude-exit.ts +2 -2
- package/src/adapters/claude-headless-mirror.ts +18 -2
- package/src/adapters/claude-headless-refusal.ts +3 -3
- package/src/adapters/claude-headless.ts +11 -10
- package/src/adapters/claude-launch-exit.ts +1 -1
- package/src/adapters/claude-monitor-delivery-plan.ts +36 -16
- package/src/adapters/claude-permission.ts +5 -5
- package/src/adapters/claude-prompt-gates.ts +9 -9
- package/src/adapters/claude-prompt-hook.ts +2 -2
- package/src/adapters/claude-session-capture.ts +6 -6
- package/src/adapters/claude-session-probe.ts +25 -25
- package/src/adapters/claude-shutdown.ts +11 -11
- package/src/adapters/claude-status-detectors.ts +31 -31
- package/src/adapters/claude-subagents.ts +7 -7
- package/src/adapters/claude-submit-confirmation.ts +6 -6
- package/src/adapters/claude-tmux.ts +43 -43
- package/src/adapters/claude-token-usage-capture.ts +5 -5
- package/src/adapters/claude-tool-policy.ts +7 -7
- package/src/adapters/claude-transcript.ts +24 -24
- package/src/adapters/claude.ts +100 -100
- package/src/adapters/codex-agent-message-capture.ts +26 -63
- package/src/adapters/codex-app-server.ts +1 -1
- package/src/adapters/codex-client.ts +9 -9
- package/src/adapters/codex-effective-config.ts +1 -1
- package/src/adapters/codex-launch-config.ts +5 -5
- package/src/adapters/codex-rollout-context.ts +2 -2
- package/src/adapters/codex-skill-discovery.ts +1 -1
- package/src/adapters/codex-token-usage-capture.ts +16 -16
- package/src/adapters/codex-tool-output.ts +4 -4
- package/src/adapters/codex-version.ts +3 -3
- package/src/adapters/codex.ts +64 -64
- package/src/adapters/opencode-api.ts +1 -1
- package/src/adapters/opencode-context.ts +20 -22
- package/src/adapters/opencode-model-registry.ts +7 -7
- package/src/adapters/opencode-provider.ts +8 -8
- package/src/adapters/opencode-provisioning.ts +30 -0
- package/src/adapters/opencode-server-retry.ts +1 -1
- package/src/adapters/opencode-soft-steer.ts +5 -5
- package/src/adapters/opencode-spawn.ts +18 -14
- package/src/adapters/opencode-token-usage-capture.ts +12 -12
- package/src/adapters/opencode-tool-events.ts +15 -15
- package/src/adapters/opencode.ts +232 -232
- package/src/adapters/project-shell.ts +11 -11
- package/src/adapters/service-access.ts +6 -6
- package/src/adapters/tmux-offload.ts +9 -9
- package/src/adapters/tool-summary.ts +5 -5
- package/src/agent-redact.ts +1 -1
- package/src/agent-runner/commands/dispatch.ts +10 -10
- package/src/agent-runner/commands/lifecycle.ts +2 -2
- package/src/agent-runner/commands/prompt.ts +19 -19
- package/src/agent-runner/credentials.ts +5 -5
- package/src/agent-runner/inbox.ts +1 -1
- package/src/agent-runner/lifecycle-milestones.ts +3 -3
- package/src/agent-runner/lifecycle-phase.ts +2 -2
- package/src/agent-runner/liveness-exchange.ts +4 -10
- package/src/agent-runner/registration.ts +8 -8
- package/src/agent-runner/runner-context.ts +9 -14
- package/src/agent-runner/session-mirror.ts +12 -12
- package/src/agent-runner/task-claims.ts +2 -2
- package/src/agent-runner/token-usage-coverage.ts +1 -1
- package/src/agent-runner/turn-report.ts +12 -12
- package/src/api-budget-accounting.ts +3 -3
- package/src/area-memory.ts +6 -6
- package/src/artifact-path-materialize.ts +1 -1
- package/src/async-guard.ts +5 -5
- package/src/attachment-cache.ts +17 -22
- package/src/background-script-reconcile.ts +6 -6
- package/src/bounded-log.ts +1 -1
- package/src/busy-reconciler.ts +22 -22
- package/src/claim-tracker.ts +3 -3
- package/src/claude-settings.ts +8 -8
- package/src/claude-system-prompt.ts +1 -1
- package/src/claude-workspace-trust.ts +1 -1
- package/src/config.ts +62 -61
- package/src/context-boundary.ts +5 -5
- package/src/context-command.ts +3 -3
- package/src/control-server.ts +32 -32
- package/src/delivery-result.ts +2 -2
- package/src/effort-command.ts +2 -2
- package/src/empty-turn.ts +22 -14
- package/src/free-port.ts +1 -1
- package/src/gate-run-config.ts +3 -3
- package/src/host-asset-plan.ts +24 -17
- package/src/host-claude-config-worker.ts +2 -2
- package/src/host-sources.ts +2 -2
- package/src/index.ts +16 -16
- package/src/launch-assembly.ts +94 -96
- package/src/liveness.ts +15 -15
- package/src/logger.ts +3 -3
- package/src/mcp-outbox.ts +3 -3
- package/src/mcp-preflight.ts +2 -2
- package/src/mcp-source-selection.ts +2 -2
- package/src/mcp-tool-deferral.ts +1 -1
- package/src/mcp-vault-env.ts +2 -2
- package/src/message-body-config/config.ts +4 -4
- package/src/mid-turn-delivery.ts +2 -2
- package/src/native-memory-lever.ts +8 -8
- package/src/outbox.ts +15 -15
- package/src/pending-permission-store.ts +1 -1
- package/src/profile-home.ts +19 -19
- package/src/profile-projection.ts +26 -24
- package/src/project-instructions.ts +5 -5
- package/src/provider-auth-warning.ts +17 -0
- package/src/provider-double.ts +1 -1
- package/src/provider-hold.ts +3 -3
- package/src/provider-home-marker.ts +1 -1
- package/src/provider-home-tombstone.ts +1 -1
- package/src/provider-input-queue.ts +2 -2
- package/src/provider-integration.ts +15 -13
- package/src/provider-setup.ts +15 -3
- package/src/providers.ts +1 -1
- package/src/provisioning.ts +37 -40
- package/src/question-decision.ts +2 -2
- package/src/quota.ts +0 -6
- package/src/rate-limit.ts +24 -13
- package/src/refused-prompt-store.ts +2 -2
- package/src/relay-injection-events.ts +2 -2
- package/src/relay-instructions.ts +5 -5
- package/src/relay-mcp-proxy.long-turn-e2e.ts +13 -13
- package/src/relay-mcp-proxy.ts +57 -58
- package/src/relay-mcp.ts +55 -69
- package/src/reply-obligation-cache.ts +9 -9
- package/src/response-capture-report.ts +9 -9
- package/src/response-routing.ts +1 -1
- package/src/runner-core.ts +209 -222
- package/src/runner-exit.ts +1 -1
- package/src/runner-helpers.ts +10 -10
- package/src/runner-insights.ts +4 -2
- package/src/runner-mcp-proxy.ts +3 -3
- package/src/runner-spawn-config.ts +3 -3
- package/src/runtime-token-file.ts +1 -1
- package/src/runtime-token-remint.ts +1 -1
- package/src/service-access.ts +2 -2
- package/src/session-destroy.ts +1 -1
- package/src/session-insights.ts +1 -1
- package/src/session-scratch.ts +24 -37
- package/src/spawn-input.ts +6 -6
- package/src/startup-config-fetch.ts +4 -4
- package/src/subreaper.ts +3 -5
- package/src/test-run-dedup.ts +13 -13
- package/src/transcript-context.ts +2 -2
- package/src/transport-errors.ts +1 -1
- package/src/turn-opener.ts +1 -1
- package/src/turn-reconcile-event.ts +2 -2
- package/src/vault-proxy-hop.ts +2 -2
- package/src/web-tools.ts +2 -2
- package/agent-bin/agent-relay-browser +0 -10
- package/src/adapters/provider-quota-harvest.ts +0 -76
package/src/adapters/opencode.ts
CHANGED
|
@@ -27,9 +27,9 @@ import { OPENCODE_SOFT_STEER_POST_TIMEOUT_MS, OPENCODE_SOFT_STEER_RECONCILE_TIME
|
|
|
27
27
|
export { openCodeContextThreshold, openCodeContextWindowTokens, openCodeModelDescriptor, openCodeModelRecord, openCodeQualifiedModel } from "./opencode-provider";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* the non-secret attachment generation for a web-terminal guest: a short one-way
|
|
31
31
|
* digest of the ACTIVE OpenCode session id. It changes exactly when the session rotates
|
|
32
|
-
* (clearContext / the
|
|
32
|
+
* (clearContext / the retry re-roll) and lets the orchestrator detect a stale guest
|
|
33
33
|
* TUI — attached to a dead conversation — WITHOUT ever seeing the session id itself or any
|
|
34
34
|
* credential (a digest of a 122-bit-random uuid is not reversible in practice). Rides the
|
|
35
35
|
* attach spec, the runner's GET /terminal/generation, and the orchestrator's guest registry;
|
|
@@ -47,7 +47,7 @@ export interface OpenCodeServerEvent {
|
|
|
47
47
|
type OpenCodeFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* Bun extends its fetch init with a per-request `timeout` field that the standard
|
|
51
51
|
* RequestInit type does not declare (`number | false`; `false` disables Bun's native HTTP idle
|
|
52
52
|
* watchdog for that one request). Neither @types/node's nor bun-types 1.3.14's ambient
|
|
53
53
|
* RequestInit/BunFetchRequestInit declares the field, so it is declared here rather than reached
|
|
@@ -60,7 +60,7 @@ type OpenCodeFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<R
|
|
|
60
60
|
export type OpenCodeRequestInit = RequestInit & { timeout?: number | false };
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* whole-turn budget for `OpenCodeClient.sendMessage`'s synchronous POST (see its doc).
|
|
64
64
|
* Env-tunable so an operator can trade a tighter bound for faster abandonment-detection on a
|
|
65
65
|
* flaky network, or a longer one for a model/workload that legitimately runs longer than an hour.
|
|
66
66
|
* The default is intentionally large rather than unbounded — `AbortSignal.timeout` requires a
|
|
@@ -136,14 +136,14 @@ export class OpenCodeClient {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
*
|
|
139
|
+
* this POST is the SYNCHRONOUS endpoint: it returns only once the assistant message is
|
|
140
140
|
* complete, so its status and duration describe the whole turn, not just the hand-off.
|
|
141
141
|
*
|
|
142
142
|
* It used to be entirely silent on success — no status, no timing, no access log — so when a
|
|
143
143
|
* spawned agent idled forever there was no way to establish whether its first prompt had even
|
|
144
144
|
* been accepted; that took reading the provider's own retained SQLite. Log it.
|
|
145
145
|
*
|
|
146
|
-
*
|
|
146
|
+
* this call used to carry NO abort signal at all, yet a live, still-running turn was
|
|
147
147
|
* observed being cut off client-side at roughly 294s regardless. Combined with the old PERMANENT
|
|
148
148
|
* idle suppression, that stranded the agent's status at "idle" forever even though the server
|
|
149
149
|
* kept working — see the `suppressProviderIdle` field doc on `OpenCodeAdapter`. Passing an
|
|
@@ -151,7 +151,7 @@ export class OpenCodeClient {
|
|
|
151
151
|
* never the thing that trips it, and gives `send()` a deterministic abandonment point to recover
|
|
152
152
|
* from if it ever does.
|
|
153
153
|
*
|
|
154
|
-
*
|
|
154
|
+
* ROOT CAUSED (Bun repro): that ~294s cutoff is Bun's native HTTP idle watchdog — a
|
|
155
155
|
* fetch whose response sends no bytes for BUN_CONFIG_HTTP_IDLE_TIMEOUT (default 300s) dies
|
|
156
156
|
* client-side with TimeoutError "The operation timed out" INDEPENDENTLY of the AbortSignal
|
|
157
157
|
* above, which bounds only its own much longer duration. This endpoint is synchronous, so any
|
|
@@ -171,7 +171,7 @@ export class OpenCodeClient {
|
|
|
171
171
|
headers: { "Content-Type": "application/json" },
|
|
172
172
|
body: JSON.stringify({ model: openCodeTurnDescriptor(model, this.provider), parts: [{ type: "text", text }] }),
|
|
173
173
|
signal: AbortSignal.timeout(timeoutMs),
|
|
174
|
-
//
|
|
174
|
+
// disable Bun's native HTTP idle watchdog for this whole-turn request only; the
|
|
175
175
|
// signal above remains the sole deadline. See OpenCodeRequestInit for the verified semantics.
|
|
176
176
|
timeout: false,
|
|
177
177
|
});
|
|
@@ -185,7 +185,7 @@ export class OpenCodeClient {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
*
|
|
188
|
+
* bounded read of a session's persisted messages, used ONLY by the acceptance
|
|
189
189
|
* reconciliation when a context-carrying turn's completion await failed: a user message whose
|
|
190
190
|
* persisted text equals the outbound prompt is positive proof the server ACCEPTED it, even
|
|
191
191
|
* though the await never resolved. Kept on a short, explicit deadline — this runs on a failure
|
|
@@ -197,7 +197,7 @@ export class OpenCodeClient {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
|
-
*
|
|
200
|
+
* Phase B — the soft-steer persist primitive: POST /session/:id/message with a CALLER
|
|
201
201
|
* messageID + noReply:true. LIVE-PROVEN on pinned 1.18.21: persists the user row in single-digit
|
|
202
202
|
* milliseconds WITHOUT joining/cancelling an in-flight run (does not touch the runner effect that
|
|
203
203
|
* prompt_async would join), and the response body IS the persisted user message — so
|
|
@@ -227,7 +227,7 @@ export class OpenCodeClient {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
|
-
*
|
|
230
|
+
* Phase B — the ambiguous-outcome reconcile: does the session's persisted message list
|
|
231
231
|
* contain EXACTLY this caller messageID? true/false are definitive (the GET succeeded and the
|
|
232
232
|
* full session listing either has the row or does not); undefined means the listing itself was
|
|
233
233
|
* unavailable — still ambiguous. Matching is by exact id, never by text (a resend must never
|
|
@@ -248,10 +248,10 @@ export class OpenCodeClient {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
251
|
+
* OpenCode's own native session-compaction endpoint (a prior effort, wrongly
|
|
252
252
|
* concluded it did not exist and shipped clear-inject only). Like `sendMessage`, this may run
|
|
253
253
|
* long server-side, so it gets the same Bun idle-watchdog bypass (`timeout: false`) plus an
|
|
254
|
-
* explicit bounded `AbortSignal` — see `sendMessage`'s doc for the verified
|
|
254
|
+
* explicit bounded `AbortSignal` — see `sendMessage`'s doc for the verified semantics.
|
|
255
255
|
* A session-not-found / compact-already-in-progress / server error all surface as the same
|
|
256
256
|
* `request()`-thrown `HTTP <status>` error; the adapter's caller resets its busy hold on catch.
|
|
257
257
|
*/
|
|
@@ -278,8 +278,8 @@ export class OpenCodeClient {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
|
-
* Retry/re-roll policy for a retryable turn-ending provider error
|
|
282
|
-
*
|
|
281
|
+
* Retry/re-roll policy for a retryable turn-ending provider error. Direct measurement
|
|
282
|
+
* showed these failures are BIMODAL per OpenCode session — a session either
|
|
283
283
|
* succeeds on every attempt or fails on every attempt for its whole life — so retrying harder
|
|
284
284
|
* into the SAME session eventually accomplishes nothing. Bound the in-session retry tightly, then
|
|
285
285
|
* re-roll to a fresh session (new session id, same server process) exactly once before giving up.
|
|
@@ -289,24 +289,24 @@ const OPENCODE_ERROR_RETRY_BASE_DELAY_MS = 1_000;
|
|
|
289
289
|
|
|
290
290
|
/** Exponential backoff for retry attempt N (1-indexed): 1s, 2s, 4s at the defaults above. Driven
|
|
291
291
|
* entirely by our own policy — OpenRouter sends neither `retry-after` nor `x-ratelimit-*` for
|
|
292
|
-
* this error class
|
|
292
|
+
* this error class, so there is nothing external to read. */
|
|
293
293
|
export function openCodeErrorRetryDelayMs(attempt: number): number {
|
|
294
294
|
return OPENCODE_ERROR_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
*
|
|
298
|
+
* a parsed assistant-message error off the pinned v1.18.21 wire.
|
|
299
299
|
*
|
|
300
300
|
* `AssistantMessage.error` is a union (ProviderAuthError | UnknownError |
|
|
301
301
|
* MessageOutputLengthError | MessageAbortedError | ApiError — see @opencode-ai/sdk's generated
|
|
302
302
|
* types). Only `ApiError` carries a structured `statusCode`/`isRetryable`; OpenCode falls back to
|
|
303
|
-
* `UnknownError` for anything it doesn't recognize. Verified live
|
|
303
|
+
* `UnknownError` for anything it doesn't recognize. Verified live: a real OpenRouter 429
|
|
304
304
|
* arrived as `UnknownError` with the raw provider response embedded as a JSON STRING in
|
|
305
305
|
* `data.message` (`{"code":429,"message":"...","metadata":{"error_type":"rate_limit_exceeded"}}`),
|
|
306
306
|
* not as a structured ApiError. So this parses defensively — peeling that nested JSON when
|
|
307
307
|
* present — rather than trusting a shape that is usually absent for the errors that matter most.
|
|
308
308
|
*/
|
|
309
|
-
/**
|
|
309
|
+
/** how one `send` turn ended; see its doc. */
|
|
310
310
|
type OpenCodeSendOutcome = "ok" | "failed" | "held";
|
|
311
311
|
|
|
312
312
|
export interface OpenCodeAssistantError {
|
|
@@ -349,7 +349,7 @@ export function parseOpenCodeAssistantError(raw: unknown): OpenCodeAssistantErro
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
|
-
*
|
|
352
|
+
* a 404 from OpenCode's own session-message endpoint means OpenRouter has no route for
|
|
353
353
|
* the requested provider/model at all (retired, mistyped, or never released) — generic across
|
|
354
354
|
* ANY OpenRouter-routed model, never a specific one. Classifying it as the SDK's canonical
|
|
355
355
|
* `model-unavailable` reason (already used by the Claude adapter for its own "model doesn't
|
|
@@ -361,7 +361,7 @@ export function openCodeTerminalFailureReason(error: Pick<OpenCodeAssistantError
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
/**
|
|
364
|
-
*
|
|
364
|
+
* the operator-visible providerState for "still trying" (retrying in-session, or
|
|
365
365
|
* re-rolled to a fresh session and retrying), reusing `buildRateLimitProviderState`'s shape
|
|
366
366
|
* (label/kind/source/errorType/timestamps) rather than a bespoke one. `reason` is overridden away
|
|
367
367
|
* from `RATE_LIMIT_BLOCK_REASON` ("rate_limit") deliberately: that reason is special-cased in
|
|
@@ -372,7 +372,7 @@ export function openCodeTerminalFailureReason(error: Pick<OpenCodeAssistantError
|
|
|
372
372
|
* ordinary busy-claim path (the same path `permission.asked` already uses below).
|
|
373
373
|
*/
|
|
374
374
|
export function openCodeUpstreamRetryState(input: { attempt: number; rerolled: boolean; message: string; errorType?: string }): ProviderState {
|
|
375
|
-
//
|
|
375
|
+
// round 2 (F7) — only call it a rate limit when the parsed error actually said so.
|
|
376
376
|
// `errorType` used to default to "rate_limit_exceeded" here, and the label was hardcoded to
|
|
377
377
|
// "Rate-limited upstream" regardless of cause, so a 500/503 (isRetryable via the >=500 branch
|
|
378
378
|
// in parseOpenCodeAssistantError, with no errorType at all) was misreported as a rate limit.
|
|
@@ -397,7 +397,7 @@ export function openCodeUpstreamRetryState(input: { attempt: number; rerolled: b
|
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
/**
|
|
400
|
-
*
|
|
400
|
+
* what the synchronous `/message` POST hands back: the completed assistant message.
|
|
401
401
|
*
|
|
402
402
|
* This is ground truth for the incident and needs no inference at all. The stranded agent's turn
|
|
403
403
|
* came back `finish=stop` with no text part, no tool part and zero tokens — an empty completion the
|
|
@@ -406,7 +406,7 @@ export function openCodeUpstreamRetryState(input: { attempt: number; rerolled: b
|
|
|
406
406
|
* Read defensively: OpenCode's wire is only pinned at v1.18.21, so an unrecognised shape yields
|
|
407
407
|
* `undefined` counts and simply contributes no signal, leaving the cross-provider ledger to decide.
|
|
408
408
|
*
|
|
409
|
-
*
|
|
409
|
+
* also carries the assistant message's `error`, when positively parseable. This is a
|
|
410
410
|
* STRONGER signal than `empty`: a turn can end with a non-empty `parts` count (the live incident's
|
|
411
411
|
* completion carried one bare step-start part) and still be a turn-ending PROVIDER failure, so
|
|
412
412
|
* `error` is checked ahead of (and independently of) the `empty` heuristic by every reader.
|
|
@@ -414,7 +414,7 @@ export function openCodeUpstreamRetryState(input: { attempt: number; rerolled: b
|
|
|
414
414
|
export interface OpenCodeCompletion {
|
|
415
415
|
parts?: number;
|
|
416
416
|
/**
|
|
417
|
-
*
|
|
417
|
+
* round 3 (HIGH-1) — `parts` count MINUS bookkeeping-only parts (`step-start`,
|
|
418
418
|
* `step-finish`), which the server emits on every turn — including one that produced nothing
|
|
419
419
|
* but a provider error. The live incident this issue was filed on captured exactly that shape:
|
|
420
420
|
* one bare `step-start` part, zero tokens, a 429. `parts` alone is 1, which reads as "the model
|
|
@@ -425,8 +425,8 @@ export interface OpenCodeCompletion {
|
|
|
425
425
|
finish?: string;
|
|
426
426
|
error?: OpenCodeAssistantError;
|
|
427
427
|
/**
|
|
428
|
-
*
|
|
429
|
-
* synchronous POST returns the completed assistant message
|
|
428
|
+
* the terminal assistant message's TEXT, concatenated from its text parts. This
|
|
429
|
+
* synchronous POST returns the completed assistant message, so the
|
|
430
430
|
* turn-final response is read from it directly rather than reconstructed from a possibly-late
|
|
431
431
|
* or lost SSE frame. Present only when the completed message carried assistant text; absent for
|
|
432
432
|
* a tools-only or empty turn.
|
|
@@ -435,7 +435,7 @@ export interface OpenCodeCompletion {
|
|
|
435
435
|
/** True only when the shape WAS understood and it says the model produced nothing. */
|
|
436
436
|
empty: boolean;
|
|
437
437
|
/**
|
|
438
|
-
*
|
|
438
|
+
* the terminal assistant message's REASONING as per-part steps (`{stepId, body}`,
|
|
439
439
|
* wire order). Present only when the completed message carried non-empty reasoning; absent
|
|
440
440
|
* for text/tools-only or empty turns. Per-part identity (not a joined string) is what lets
|
|
441
441
|
* the turn-final emit upsert the live SSE rows instead of duplicating them — the server keys
|
|
@@ -444,13 +444,13 @@ export interface OpenCodeCompletion {
|
|
|
444
444
|
*/
|
|
445
445
|
reasoningSteps?: OpenCodeReasoningStep[];
|
|
446
446
|
/**
|
|
447
|
-
*
|
|
447
|
+
* the same reasoning as one joined string (the steps' bodies, `\n\n` join mirroring
|
|
448
448
|
* `text`). Kept for the completion log line only — the EMIT path reads `reasoningSteps`.
|
|
449
449
|
*/
|
|
450
450
|
reasoning?: string;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
/**
|
|
453
|
+
/** round 3 (HIGH-1) — a bookkeeping marker the server emits around every turn regardless of
|
|
454
454
|
* outcome, never evidence the model produced anything. An unrecognised part shape is counted as
|
|
455
455
|
* output (the conservative default): under-counting risks re-treating real work as empty and
|
|
456
456
|
* re-executing it, which is the worse failure. */
|
|
@@ -471,7 +471,7 @@ function countTokens(value: unknown): number | undefined {
|
|
|
471
471
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
472
472
|
if (!isRecord(value)) return undefined;
|
|
473
473
|
// `{ input, output, reasoning, cache: {...} }` on the pinned version, plus — confirmed against a
|
|
474
|
-
// real captured v1.18.21 session
|
|
474
|
+
// real captured v1.18.21 session — a top-level `total` that is ALREADY the sum of every
|
|
475
475
|
// other leaf here (`input + output + reasoning + cache.read + cache.write`). When present it is
|
|
476
476
|
// authoritative and must be returned directly: falling through to the leaf sum below as well
|
|
477
477
|
// would double-count it (`total` + its own constituent parts).
|
|
@@ -511,7 +511,7 @@ export function openCodeCompletion(body: unknown): OpenCodeCompletion {
|
|
|
511
511
|
...(reasoning ? { reasoning } : {}),
|
|
512
512
|
// Only claim emptiness when the shape was actually understood: a body we could not parse must
|
|
513
513
|
// never be reported as an empty completion.
|
|
514
|
-
//
|
|
514
|
+
// round 3 review (LOW-4r) — must agree with `completionProducedOutput`'s `outputParts`,
|
|
515
515
|
// not raw `parts`, or a bookkeeping-only body (step-start, tokens:0, no error) slips past BOTH
|
|
516
516
|
// predicates in send(): not `producedOutput` (correctly), but also not `empty` — landing on
|
|
517
517
|
// "if (!completion.empty) return true" with no turnActivityCb, no retry-hold clear, and no
|
|
@@ -530,7 +530,7 @@ function describeCompletion(completion: OpenCodeCompletion): string {
|
|
|
530
530
|
return ` → ${bits.join(", ")}${completion.empty ? " — EMPTY COMPLETION" : ""}`;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
/** Bound on the live per-session bookkeeping in `OpenCodeAdapter
|
|
533
|
+
/** Bound on the live per-session bookkeeping in `OpenCodeAdapter`: a headless agent can
|
|
534
534
|
* run for days across many turns, so unbounded maps keyed by message/part id would leak. Both
|
|
535
535
|
* `messageRoles` and `emittedTextParts` are pruned back to this size, oldest first, whenever a
|
|
536
536
|
* write would exceed it. */
|
|
@@ -551,7 +551,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
551
551
|
readonly reportsContextResets = true;
|
|
552
552
|
readonly provider: string;
|
|
553
553
|
readonly statusSourceId: string;
|
|
554
|
-
//
|
|
554
|
+
// unlocks `context.resume = "native"` capability advertisement (see
|
|
555
555
|
// runtimeProviderContextCapabilities). `opts.instructions` is deliberately NOT threaded into the
|
|
556
556
|
// `/compact` HTTP call itself (see `performCompact`'s doc) — the relay's own reactive
|
|
557
557
|
// continuation inject already delivers it as a follow-up message either way, exactly like
|
|
@@ -560,7 +560,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
560
560
|
// OpenCode's `/compact` endpoint is NOT instruction-steerable — `performCompact` never sends the
|
|
561
561
|
// continuation on the wire at all, so the summarizer itself never sees it. Setting this flag true
|
|
562
562
|
// is what makes `resume:"native"` selectable; the actual delivery guarantee comes entirely from
|
|
563
|
-
// the reactive inject, not from this flag.
|
|
563
|
+
// the reactive inject, not from this flag. Tracks a cleaner capability bit that
|
|
564
564
|
// distinguishes "native resume is offered" from "the compact call itself accepts steering text",
|
|
565
565
|
// so a future reader doesn't read this as "the summary is instructable" when it is not.
|
|
566
566
|
readonly compactSupportsInstructions = true;
|
|
@@ -568,53 +568,53 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
568
568
|
private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
|
|
569
569
|
private turnActivityCb: () => void = () => {};
|
|
570
570
|
// `message.updated` (role) always precedes that message's `message.part.updated` events on the
|
|
571
|
-
// real v1.18.21 wire (verified live against OpenRouter meta/muse-spark-1.3-contributor
|
|
571
|
+
// real v1.18.21 wire (verified live against OpenRouter meta/muse-spark-1.3-contributor) — spawn
|
|
572
572
|
// subscribes before any prompt is sent, so there is no race for the normal launch flow.
|
|
573
573
|
private readonly messageRoles = new Map<string, string>();
|
|
574
574
|
private readonly emittedTextParts = new Set<string>();
|
|
575
|
-
//
|
|
575
|
+
// stepId → highest lifecycle rank already emitted for that tool part, so a duplicate or
|
|
576
576
|
// reordered `message.part.updated` (a late `running` after `completed`, a repeated frame) never
|
|
577
577
|
// regresses or double-emits the step. Pruned to OPENCODE_EVENT_STATE_LIMIT like the maps above.
|
|
578
578
|
private readonly emittedToolSteps = new Map<string, number>();
|
|
579
|
-
//
|
|
579
|
+
// the ToolPart wire shape only carries `input` while `running`; a `completed` decode has
|
|
580
580
|
// none. Cache the redacted params captured at running, keyed by stepId, and carry them forward
|
|
581
581
|
// onto the terminal event so the row's params don't vanish when the server upserts it in place
|
|
582
582
|
// (upsertSessionStepInPlace replaces the whole payload with the incoming one, not a merge).
|
|
583
583
|
private readonly toolStepParams = new Map<string, unknown>();
|
|
584
|
-
//
|
|
584
|
+
// reasoning part id → full body already emitted for that part, so a repeated or
|
|
585
585
|
// finalized `message.part.updated` frame for the same reasoning part emits the CURRENT full
|
|
586
586
|
// text (the server upserts the trace row in place by stepId) instead of appending a duplicate
|
|
587
587
|
// row. Pruned to OPENCODE_EVENT_STATE_LIMIT like the maps above.
|
|
588
588
|
private readonly emittedReasoningParts = new Map<string, string>();
|
|
589
|
-
//
|
|
589
|
+
// review (HIGH-1) — a genuinely PER-SEND turn id, minted at the top of each `send` and
|
|
590
590
|
// used for the busy edge, narration, tool steps, and the turn-final response alike. Was
|
|
591
591
|
// `process.meta.sessionId`, which PERSISTS across sends: two turns in one session then shared a
|
|
592
592
|
// turnId, so lineage dedup (promoteLineageCapturedResponse/flushWorkFinalReports) forwarded the
|
|
593
593
|
// FIRST turn's body and dropped the SECOND — the real final report never reached the coordinator.
|
|
594
594
|
private sendTurnId: string | undefined;
|
|
595
|
-
//
|
|
595
|
+
// round 2 (F3/F4) — live-confirmed: the real server emits its OWN busy/idle
|
|
596
596
|
// session.status pair around EVERY attempt, including failed ones (two `idle` events for one
|
|
597
597
|
// errored turn). Left alone, each failed attempt's idle looks like a real provider-turn close
|
|
598
|
-
// to runner-core's
|
|
598
|
+
// to runner-core's seam: it clears `currentTurnId` and judges that blip as an empty turn,
|
|
599
599
|
// then the next attempt's busy mints a brand-new turn id — up to
|
|
600
600
|
// `OPENCODE_ERROR_RETRY_MAX_ATTEMPTS * 2` extra empty-turn closes the operator sees, and a
|
|
601
601
|
// premature nudge/escalation mid-retry. Raised for the WHOLE `send()` call (see the loop entry),
|
|
602
602
|
// this swallows every transient idle for the call's own turn so the whole retry sequence stays
|
|
603
603
|
// ONE turn from the runner's view.
|
|
604
|
-
//
|
|
604
|
+
// round 5 review (MED-1) — this is now a STICKY suppression, not cleared at close time:
|
|
605
605
|
// `closeSuppressedTurn` used to lower it as it fired, so a HEALTHY turn's own trailing real SSE
|
|
606
606
|
// idle — if processed after the POST already resolved — sailed through unsuppressed and closed
|
|
607
607
|
// the same turn a SECOND time (duplicate reInjectPendingObligation, duplicate
|
|
608
608
|
// completeObservedTaskClaims/Task-done). The invariant that actually holds: an idle belonging to
|
|
609
609
|
// a turn `send()` already closed must stay swallowed; only an idle for a turn that STARTS AFTER
|
|
610
610
|
// that close may pass.
|
|
611
|
-
//
|
|
611
|
+
// round 6 review (MED-1r) — round 5 had `handleEvent`'s BUSY door lower this flag whenever
|
|
612
612
|
// `turnInFlight` was false, on the assumption that such a busy edge could only be a genuinely new
|
|
613
613
|
// turn. That is a timing assumption, not an invariant: `subscribe()` dispatches a whole SSE chunk
|
|
614
614
|
// synchronously in one loop, and the SSE/POST sockets have no cross-ordering guarantee, so a late
|
|
615
615
|
// own-turn busy+idle pair batched together AFTER the POST resolves cleared suppression via the
|
|
616
616
|
// busy half and then let the idle half sail straight through — double-closing the turn.
|
|
617
|
-
//
|
|
617
|
+
// round 7 review (MED-1/MED-2/LOW-1) — round 6 only made the reopen one-shot (the FIRST
|
|
618
618
|
// post-close idle lowered this flag again), which still let a SECOND trailing idle in the same
|
|
619
619
|
// window (two late idles; a 429-retry where both attempts' own idles land after send() returns;
|
|
620
620
|
// a foreign child session's busy+idle, since `handleEvent` never filters `properties.sessionID`)
|
|
@@ -623,19 +623,19 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
623
623
|
// real turn-boundary transition for a turn `send()` actually SAW END is already emitted
|
|
624
624
|
// EXPLICITLY by `send()` (its own `busy` at each attempt's start) and by
|
|
625
625
|
// `closeSuppressedTurn`/`failTurn` (their own `idle`/`error`) — never by this SSE echo.
|
|
626
|
-
//
|
|
626
|
+
// that reasoning has exactly one hole: it assumes `send` always learns how its own
|
|
627
627
|
// turn ended. `sendMessage`'s POST can instead THROW with the server-side turn's outcome
|
|
628
628
|
// unknown (a client-side timeout on a turn that keeps running upstream) — see `turnAbandoned`
|
|
629
629
|
// below. In that one case ONLY, staying permanent forever would swallow every subsequent real
|
|
630
630
|
// busy/idle for the rest of this adapter instance's life, freezing status at whatever
|
|
631
631
|
// `send()`'s own `finally` guessed. Both SSE doors below therefore also check `turnAbandoned`
|
|
632
|
-
// — the per-session filter
|
|
632
|
+
// — the per-session filter is what makes that safe: a foreign session's
|
|
633
633
|
// busy/idle can never reach here regardless of `turnAbandoned`, so lifting suppression for OUR
|
|
634
634
|
// OWN session's real events reopens no hazard round 7 was guarding against.
|
|
635
635
|
private suppressProviderIdle = false;
|
|
636
|
-
//
|
|
636
|
+
// true once THIS call's own `finally` closed the turn WITHOUT any deterministic
|
|
637
637
|
// success/failure branch having run first (i.e. `sendMessage` or something else in the retry
|
|
638
|
-
// loop threw — a client-side timeout being the motivating case,
|
|
638
|
+
// loop threw — a client-side timeout being the motivating case, the incident). The server-
|
|
639
639
|
// side outcome is genuinely unknown in that case, unlike a normal completion or `failTurn`
|
|
640
640
|
// (both of which set `turnClosed` themselves before the `finally` runs, so `turnAbandoned` stays
|
|
641
641
|
// false for them — see the `finally` block). While true, the two SSE doors above stop enforcing
|
|
@@ -645,35 +645,35 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
645
645
|
// boundary, its own explicit busy/idle is authoritative again, and any stray leftover event from
|
|
646
646
|
// a truly-abandoned previous turn must stay suppressed rather than corrupt the new turn's state.
|
|
647
647
|
private turnAbandoned = false;
|
|
648
|
-
//
|
|
648
|
+
// a session re-roll already spent in the current provider-hold episode; cleared by the
|
|
649
649
|
// first turn that goes through (send() → "ok"). Bounds a long wall to ONE history-dropping re-roll.
|
|
650
650
|
private rerolledThisHoldEpisode = false;
|
|
651
|
-
//
|
|
651
|
+
// a wall OpenCode's own retry hit during this send; the session is aborted and send holds.
|
|
652
652
|
private serverRetryWall?: OpenCodeServerRetry;
|
|
653
|
-
//
|
|
653
|
+
// round 5 review (MED-1/LOW-2) — true from the moment `send` is actually about to run an
|
|
654
654
|
// attempt (past the readiness guard, so a "session not ready" throw never sets it) through the
|
|
655
655
|
// call's `finally`.
|
|
656
|
-
//
|
|
656
|
+
// round 7 review — no longer read by either SSE door (both now key only on the permanent
|
|
657
657
|
// `suppressProviderIdle` above). Still captured by `send()`'s `finally` (round 6, MED-2r) to
|
|
658
658
|
// decide whether THIS call ever actually started an attempt, so a "session not ready" throw
|
|
659
659
|
// never triggers a close for a turn that never began.
|
|
660
660
|
private turnInFlight = false;
|
|
661
|
-
//
|
|
661
|
+
// round 5 review (MED-1) — true once THIS call's own close has already fired (via
|
|
662
662
|
// `closeSuppressedTurn` or `failTurn`), reset at the top of every `send()` call. Distinct from
|
|
663
663
|
// `suppressProviderIdle` (which deliberately stays raised after firing, per above): this is the
|
|
664
664
|
// idempotency guard that stops the SAME call's multiple close sites — an explicit branch call
|
|
665
665
|
// plus the `finally` fallback — from emitting a second idle for one call.
|
|
666
|
-
//
|
|
666
|
+
// also doubles as the double-close guard for the `turnAbandoned` escape hatch: once
|
|
667
667
|
// abandonment force-closes a turn, this stays true until a genuine same-session busy re-arms it
|
|
668
668
|
// (see the busy door), so a stray trailing idle for the SAME abandoned turn finds it already
|
|
669
669
|
// closed and is a no-op rather than a second `idle` dispatch.
|
|
670
670
|
private turnClosed = false;
|
|
671
|
-
//
|
|
671
|
+
// round 1 (HIGH) — FIFO serializer making `clearContext` safe against an IN-FLIGHT turn.
|
|
672
672
|
// `agent.clearContext` is dispatched fire-and-forget (dispatchCommand holds no provider-input
|
|
673
673
|
// ticket for it — PROVIDER_INPUT_COMMAND_TYPES is inject-only) and OpenCode's `/message` POST is
|
|
674
|
-
// SYNCHRONOUS
|
|
674
|
+
// SYNCHRONOUS, so the model calling humwick_compact_and_resume from within its own turn
|
|
675
675
|
// means the command genuinely runs WHILE send() is still awaiting that POST. send()'s retry loop
|
|
676
|
-
// re-reads process.meta.sessionId every iteration and cannot tell its own
|
|
676
|
+
// re-reads process.meta.sessionId every iteration and cannot tell its own reroll from an
|
|
677
677
|
// external swap, so an unserialized clear redirected the live turn's retries/rerolls — and their
|
|
678
678
|
// reportContextUsage writes — straight into the freshly cleared session. Every writer of that
|
|
679
679
|
// per-call state therefore goes through this queue: both send() call sites (deliverInitialPrompt/
|
|
@@ -686,11 +686,11 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
686
686
|
// command early, because "clear anyway on timeout" would reintroduce exactly this bug.
|
|
687
687
|
private turnQueue: Promise<unknown> = Promise.resolve();
|
|
688
688
|
|
|
689
|
-
// ───
|
|
689
|
+
// ─── Phase B — non-destructive soft-steer lane (default-OFF kill switch) ───
|
|
690
690
|
// Durable-until-consumed ledger for positively-persisted soft batches (persistence != delivery).
|
|
691
691
|
private readonly softSteer = new OpenCodeSoftSteerLedger();
|
|
692
692
|
// The soft lane's OWN one-in-flight FIFO (fast loopback POSTs) — deliberately NOT turnQueue:
|
|
693
|
-
// serializing soft steers behind whole turns is the 84-minute
|
|
693
|
+
// serializing soft steers behind whole turns is the 84-minute delay this lane exists to
|
|
694
694
|
// remove. Only the POST+reconcile run here; the wake turn (rare, fallback) goes through
|
|
695
695
|
// turnQueue like any normal turn so it owns a real busy/idle pair.
|
|
696
696
|
private softSteerQueue: Promise<unknown> = Promise.resolve();
|
|
@@ -700,7 +700,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
700
700
|
private softSteerProcess: ManagedProcess | undefined;
|
|
701
701
|
|
|
702
702
|
/**
|
|
703
|
-
*
|
|
703
|
+
* the operator-facing capability bit. DEFAULT-ON since the review-gated flip: the
|
|
704
704
|
* deployed-fleet canary soak (one opencode/zai agent, 2026-09-11) proved the end-to-end path
|
|
705
705
|
* through A's seam — fold-in, durable-until-consumed settlement, no dup/loss/hot-loop, no run
|
|
706
706
|
* disruption. The per-offer policy reads `ProviderConfig.activeTurnMessageDelivery` via
|
|
@@ -710,8 +710,8 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
710
710
|
*/
|
|
711
711
|
readonly supportsMidTurnSteer = true;
|
|
712
712
|
|
|
713
|
-
//
|
|
714
|
-
// server-initiated turns
|
|
713
|
+
// event-driven turn identity for TERMINAL-origin turns. OpenCode is headless with no
|
|
714
|
+
// server-initiated turns, so a session.status:busy arriving for
|
|
715
715
|
// the active session OUTSIDE send() can only be a prompt typed into an attached web-terminal
|
|
716
716
|
// TUI. send() is not running then, so its sticky `suppressProviderIdle` would otherwise swallow
|
|
717
717
|
// the whole turn, and the events would fall back to the STALE `sendTurnId` of the last headless
|
|
@@ -721,7 +721,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
721
721
|
// Assistant text accumulated (finalized text parts, active session) while the event turn is
|
|
722
722
|
// open, so its close can emit the turn-final response exactly like send()'s POST path does.
|
|
723
723
|
private eventTurnText: string[] = [];
|
|
724
|
-
//
|
|
724
|
+
// reasoning observed this event turn, keyed by part id to the LATEST body seen. The
|
|
725
725
|
// terminal-origin path has no completed-message POST to read back from, so the close must
|
|
726
726
|
// reconcile from what the live door saw — but keyed per part (not a body pool): a growth
|
|
727
727
|
// frame for an already-emitted part only needs the newest snapshot, and an identical body in
|
|
@@ -729,17 +729,17 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
729
729
|
// Bounded: part ids are server-unique per turn and the map is reset every turn, so it holds
|
|
730
730
|
// at most one entry per reasoning part of the CURRENT turn — no prefix scan, no growth.
|
|
731
731
|
private eventTurnReasoning = new Map<string, string>();
|
|
732
|
-
//
|
|
732
|
+
// round 1 (review D) — the EVENT-SOURCE discriminator replacing the round-0 time
|
|
733
733
|
// window. A terminal-origin turn is always PRECEDED by its prompt: the attached TUI's
|
|
734
734
|
// POST creates the user message on the active session before/while the turn's busy edge
|
|
735
735
|
// flows, so the message.updated(role=user) door arms this flag. The busy door admits a
|
|
736
|
-
// terminal turn only when armed (or when no headless turn ever suppressed, or
|
|
736
|
+
// terminal turn only when armed (or when no headless turn ever suppressed, or
|
|
737
737
|
// abandonment). A HEADLESS echo — the buffered busy+idle pair of a turn send() just
|
|
738
738
|
// closed — has NO such fresh user message by construction, so it stays suppressed with NO
|
|
739
739
|
// time bound: a delayed echo of any age is indistinguishable from a stale one and both are
|
|
740
|
-
// dropped, restoring
|
|
740
|
+
// dropped, restoring round-7 permanence positively instead of heuristically.
|
|
741
741
|
private pendingTerminalPrompt = false;
|
|
742
|
-
//
|
|
742
|
+
// round 2 (review D) — the terminal user prompt BUFFER. The [user message.updated →
|
|
743
743
|
// user part → busy] wire ordering means the prompt can arrive BEFORE the busy that mints
|
|
744
744
|
// the terminal turn id; buffering keeps the prompt from publishing a raw session id as its
|
|
745
745
|
// turnId and from splitting off from its trace/final. SINGLE SLOT (the bound): a newer
|
|
@@ -748,13 +748,13 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
748
748
|
// send() takeover, and the clear/re-roll swap — the fallback id is ALWAYS the session
|
|
749
749
|
// pseudo-turn or the minted id, never a stale headless sendTurnId.
|
|
750
750
|
private bufferedTerminalPrompt: { body: string; occurredAt: number } | undefined;
|
|
751
|
-
//
|
|
751
|
+
// round 1 (review D) — permanent marker that at least one headless turn ran on this
|
|
752
752
|
// process. Once true, the busy door admits a terminal-origin turn ONLY through the armed
|
|
753
753
|
// `pendingTerminalPrompt` (fresh first-seen user message), with NO time bound: a headless
|
|
754
754
|
// busy echo of any age stays suppressed. Fresh adapters (no headless turn yet) admit freely,
|
|
755
755
|
// preserving pure-terminal sessions whose busy arrives before any user-message echo.
|
|
756
756
|
private headlessTurnSeen = false;
|
|
757
|
-
//
|
|
757
|
+
// round 1 (review D) — user-message ids already observed on this process (bounded like
|
|
758
758
|
// the other event-state maps). A REPEAT user message.updated is a buffered duplicate of a
|
|
759
759
|
// turn that already ran (send()'s own POST echo delivered late) and must never re-arm the
|
|
760
760
|
// terminal gate; only a FIRST-SEEN user message is evidence of a freshly typed TUI prompt.
|
|
@@ -762,11 +762,11 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
762
762
|
|
|
763
763
|
private readonly sleepImpl: (ms: number) => Promise<void>;
|
|
764
764
|
|
|
765
|
-
//
|
|
765
|
+
// the token-usage spine transport, wired by runner-core.
|
|
766
766
|
private readonly apiBudgets = new WeakMap<ManagedProcess, OpenCodeApiBudget>();
|
|
767
767
|
|
|
768
768
|
private tokenUsageReporter?: ProviderTokenUsageReporter;
|
|
769
|
-
//
|
|
769
|
+
// round 1 review (F2/F3) — collapses the message.updated door's streaming echoes to one
|
|
770
770
|
// spine post per message; flushed early at real turn boundaries (see flushAll() call sites).
|
|
771
771
|
private readonly tokenUsagePoster = new OpenCodeTokenUsagePoster(() => this.tokenUsageReporter);
|
|
772
772
|
|
|
@@ -796,11 +796,11 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
796
796
|
this.tokenUsageReporter = reporter;
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
-
//
|
|
799
|
+
// round 2 review (MED-1) — the runner's ONE awaited, time-boxed teardown seam
|
|
800
800
|
// (RunnerCore.settleTerminalTokenUsage, wired at stop() / restartUnexpectedProviderExit /
|
|
801
801
|
// runPreSessionDestroy) previously no-op'd for OpenCode because it bails on a falsy
|
|
802
802
|
// transcriptPath — a Claude-shaped precondition OpenCode never has (it has no transcript file
|
|
803
|
-
// to tail;
|
|
803
|
+
// to tail; the capture is event-sourced from the message.updated SSE door). Owning that
|
|
804
804
|
// bail here — ignore transcriptPath, just settle the poster — means the crash-exit and
|
|
805
805
|
// pre-session-destroy paths (which never call adapter.shutdown()) now reach the same debounced
|
|
806
806
|
// report shutdown() already flushes, instead of losing the session's final token counts.
|
|
@@ -818,7 +818,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
818
818
|
openCodeApiKey(config, launch.qualified);
|
|
819
819
|
const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig, launch);
|
|
820
820
|
const apiBudget = launch.backendID === "openai" ? new OpenCodeApiBudget(config) : undefined;
|
|
821
|
-
await apiBudget?.start(relayEnv(config.env, "
|
|
821
|
+
await apiBudget?.start(relayEnv(config.env, "HUMWICK_PROVIDER_SESSION_ID") ?? crypto.randomUUID());
|
|
822
822
|
const server = await spawnOpenCodeServer(args, apiBudget);
|
|
823
823
|
const password = args.env.OPENCODE_SERVER_PASSWORD!;
|
|
824
824
|
const client = new OpenCodeClient(args.env.OPENCODE_SERVER_URL!, password, fetch, this.provider);
|
|
@@ -827,23 +827,23 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
827
827
|
const sessionTitle = config.label ?? config.agentId;
|
|
828
828
|
const sessionId = await client.createSession(sessionTitle);
|
|
829
829
|
const events = new AbortController();
|
|
830
|
-
// sessionTitle is also read by a
|
|
830
|
+
// sessionTitle is also read by a session re-roll (a fresh session created mid-flight
|
|
831
831
|
// after repeated retryable errors); sessionId is MUTATED IN PLACE there, so every other
|
|
832
832
|
// reader of process.meta.sessionId (send/interrupt/shutdown) picks the new session up too.
|
|
833
|
-
//
|
|
833
|
+
// the launch metadata terminalAttachSpec needs is retained here as well:
|
|
834
834
|
// resolved executable, loopback server URL, agent cwd and the server password. These live
|
|
835
835
|
// ONLY in this runner-local memory (and the password ONLY re-emerges as the guest process
|
|
836
836
|
// env inside the attach spec); they are never persisted, logged, or returned anywhere else.
|
|
837
|
-
//
|
|
837
|
+
// the payload record (when present) pins meta.model to the server-computed
|
|
838
838
|
// qualified name, so later reportContextUsage calls keep payload precedence instead of
|
|
839
839
|
// re-deriving from a possibly-ambiguous alias. Manifest path unchanged when absent.
|
|
840
|
-
//
|
|
840
|
+
// meta.model is the normalized launch model; turns split it verbatim, never re-resolve.
|
|
841
841
|
const process: ManagedProcess = { pid: server.pid, process: server, meta: { client, sessionId, events, model: launch.qualified, launchModel: launch.qualified, sessionTitle, config, relayContextSent: false, launchContextSent: false, attachCommand: args.command, cwd: args.cwd, serverUrl: args.env.OPENCODE_SERVER_URL!, serverPassword: password, stateDir: args.env.XDG_STATE_HOME } };
|
|
842
842
|
if (apiBudget) this.apiBudgets.set(process, apiBudget);
|
|
843
|
-
//
|
|
843
|
+
// both used to be a bare `void promise.catch(...)`: a synchronous throw from
|
|
844
844
|
// `this.statusCb`/`handleEvent` inside the fulfillment or catch handler produced a SECOND,
|
|
845
845
|
// truly unhandled rejection nothing consumed (Bun exits(1) on that; nothing respawns the
|
|
846
|
-
// runner —
|
|
846
|
+
// runner —). `fireAndForget` guarantees the rejection — from either the initial call
|
|
847
847
|
// or the handler itself — is always logged, never escapes.
|
|
848
848
|
void fireAndForget(`opencode SSE subscribe (session ${sessionId})`, () => client.subscribe(events.signal, (event) => this.handleEvent(event, process)), (message) => logger.error("opencode", message));
|
|
849
849
|
void fireAndForget(`opencode server exit status callback (session ${sessionId})`, async () => {
|
|
@@ -861,19 +861,19 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
861
861
|
}
|
|
862
862
|
}
|
|
863
863
|
|
|
864
|
-
//
|
|
864
|
+
// spawn passes the registry-normalized launch model; direct callers resolve statically.
|
|
865
865
|
buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig, launch = openCodeLaunchModel(config.model, this.provider, openCodeModelRecord(config.modelRecord, this.provider))): SpawnArgs {
|
|
866
866
|
return buildOpenCodeSpawnArgs(config, providerConfig, this.provider, launch);
|
|
867
867
|
}
|
|
868
868
|
|
|
869
869
|
/**
|
|
870
|
-
*
|
|
870
|
+
* the web-terminal attach spec for an interactive `opencode attach` guest TUI,
|
|
871
871
|
* reusing the codex guest-terminal path downstream (runner /terminal/attach-spec →
|
|
872
872
|
* orchestrator guest tmux → Relay's authenticated terminal proxy → dashboard xterm).
|
|
873
873
|
*
|
|
874
874
|
* Fail-closed BEFORE readiness/session: every input is read from the launch metadata
|
|
875
875
|
* retained at spawn() and a missing piece throws rather than degrading. The command is
|
|
876
|
-
* exactly the pinned-CLI seam the
|
|
876
|
+
* exactly the pinned-CLI seam the feasibility verified against v1.18.21:
|
|
877
877
|
* `opencode attach <loopback server URL> --session <active session id> --dir <agent cwd>
|
|
878
878
|
* --pure`. `--dir` MUST equal the server/session cwd — OpenCode sessions are
|
|
879
879
|
* project-directory scoped and attach fails (or silently diverges) when the client and
|
|
@@ -916,7 +916,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
916
916
|
}
|
|
917
917
|
|
|
918
918
|
/**
|
|
919
|
-
*
|
|
919
|
+
* the CURRENT attachment generation (derived from the active session id), read by
|
|
920
920
|
* the runner control server's GET /terminal/generation so the orchestrator can detect a
|
|
921
921
|
* session rotation without learning the session id or any credential. undefined when the
|
|
922
922
|
* session is not ready (nothing to check).
|
|
@@ -927,7 +927,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
927
927
|
}
|
|
928
928
|
|
|
929
929
|
/**
|
|
930
|
-
*
|
|
930
|
+
* round 1 (HIGH) — run `operation` as the exclusive holder of the turn queue (see the
|
|
931
931
|
* `turnQueue` field doc for why this exists). FIFO: an operation enqueued while a turn is
|
|
932
932
|
* mid-flight starts only after that turn's promise settles, success or failure.
|
|
933
933
|
*/
|
|
@@ -938,23 +938,23 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
938
938
|
}
|
|
939
939
|
|
|
940
940
|
async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: PromptOfferOptions): Promise<void | "held"> {
|
|
941
|
-
//
|
|
941
|
+
// round 3 review (LOW-5r) — `send`'s boolean is intentionally NOT surfaced as a throw
|
|
942
942
|
// here: this method's `Promise<void>` contract is shared by every adapter, and a throw would
|
|
943
943
|
// route into attemptInitialPromptDelivery's (runner-core.ts) generic retry-and-eventually-
|
|
944
944
|
// give-up path, re-sending the SAME prompt on top of the retry+reroll budget `send()` already
|
|
945
945
|
// exhausted. Instead, runner-core reads `this.terminalFailure` (set by failTurn's own `error`
|
|
946
946
|
// status, cleared by the `busy` edge this very call started with) right after this resolves, so
|
|
947
947
|
// it can log the terminal outcome correctly without inventing a second retry loop.
|
|
948
|
-
//
|
|
948
|
+
// a refusal under a provider hold is surfaced ("held"): the runner parks the prompt.
|
|
949
949
|
if (await this.enqueueTurn(() => this.send(process, prompt, options?.inputObserver)) === "held") return "held";
|
|
950
950
|
}
|
|
951
951
|
|
|
952
952
|
async deliver(process: ManagedProcess, messages: Message[], ctx?: ProviderDeliverContext): Promise<DeliverResult> {
|
|
953
|
-
//
|
|
953
|
+
// Phase B — the paste-instant soft-steer routing (Phase A's single-call-path pattern,
|
|
954
954
|
// codex.ts:435): a mid-turn offer with steerable content goes through the adapter's
|
|
955
955
|
// deliverMidTurn (noReply persist, settlement deferred through ctx); boundary-only classes
|
|
956
956
|
// and the lane-off/idle cases keep the turn-end path below. Policy resolves per offer from
|
|
957
|
-
// the spawn config; this adapter's default is "steer"
|
|
957
|
+
// the spawn config; this adapter's default is "steer", "defer" the kill switch.
|
|
958
958
|
if (messages.length > 0 && this.softSteerPolicyFor(process) === "steer" && ctx?.isRecipientMidTurn?.() === true) {
|
|
959
959
|
// r3 HIGH — steer ONLY the steerable partition, never the whole batch: the omitted ids
|
|
960
960
|
// (claimables and synthetic memory/resume injections) stay in this
|
|
@@ -967,21 +967,21 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
967
967
|
const { steerable } = partitionMidTurnDelivery(messages);
|
|
968
968
|
if (steerable.length > 0) return this.deliverMidTurn(process, steerable, ctx);
|
|
969
969
|
}
|
|
970
|
-
//
|
|
970
|
+
// round 3 (MED-3) — only a message `send` actually got through (no terminal failure)
|
|
971
971
|
// is acknowledged. runner-core marks every acknowledged id read and re-queues everything else
|
|
972
972
|
// (runner-core.ts ~969-995); returning ALL ids unconditionally — the old behavior — marked a
|
|
973
973
|
// message that provoked 8 failed POSTs and a terminal failure as delivered+read though the
|
|
974
|
-
// model never processed it: recorded delivered, silently lost.
|
|
974
|
+
// model never processed it: recorded delivered, silently lost. Round 3 (MED-6) — once a
|
|
975
975
|
// message fails terminally the session is now `error`/offline, so stop rather than burn the
|
|
976
976
|
// same ~16-minute worst-case retry budget on every remaining queued message in this batch.
|
|
977
|
-
//
|
|
977
|
+
// round 3 review (MED-3r) — `terminalFailureIds` tells runner-core WHY this id was not
|
|
978
978
|
// acknowledged, so its re-drain paces with bounded exponential backoff instead of hammering an
|
|
979
979
|
// upstream that is already rate-limiting on the very next tick.
|
|
980
980
|
const acknowledgedIds: number[] = [];
|
|
981
981
|
const terminalFailureIds: number[] = [];
|
|
982
982
|
const providerHeldIds: number[] = [];
|
|
983
983
|
for (const message of messages) {
|
|
984
|
-
let disarmOpener: (() => void) | undefined; //
|
|
984
|
+
let disarmOpener: (() => void) | undefined; // each send is its own turn, opened by this message
|
|
985
985
|
try {
|
|
986
986
|
const outcome = await this.enqueueTurn(() => { disarmOpener = ctx?.openTurnFor?.([message]); return this.send(process, providerMessageText([message]), ctx?.inputObserver); });
|
|
987
987
|
if (outcome !== "ok") disarmOpener?.();
|
|
@@ -989,7 +989,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
989
989
|
terminalFailureIds.push(message.id);
|
|
990
990
|
break;
|
|
991
991
|
}
|
|
992
|
-
//
|
|
992
|
+
// refused under a provider hold: neither delivered nor failed. Stop the batch and
|
|
993
993
|
// hand the refused id AND the untried tail to the runner to park — they would only hit
|
|
994
994
|
// the same wall; the runner redelivers them once a provider turn goes through again.
|
|
995
995
|
if (outcome === "held") {
|
|
@@ -997,7 +997,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
997
997
|
break;
|
|
998
998
|
}
|
|
999
999
|
} catch (error) {
|
|
1000
|
-
//
|
|
1000
|
+
// review (LOW-3) — symmetric with runner-core's attemptInitialPromptDelivery: a
|
|
1001
1001
|
// PromptAcceptedError means the provider ACCEPTED this queued message's POST and the
|
|
1002
1002
|
// completion await failed/aborted on a still-running turn. Propagating it reached
|
|
1003
1003
|
// runner-core's drain, which requeued the whole batch and double-POSTed into that live
|
|
@@ -1012,7 +1012,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
1014
|
/**
|
|
1015
|
-
*
|
|
1015
|
+
* Phase B — the soft-steer op (see the adapter-interface doc): persist the batch into
|
|
1016
1016
|
* the in-flight turn WITHOUT touching any send() turn state. Non-negotiable invariants:
|
|
1017
1017
|
* - send()/its instance fields stay non-reentrant: this path must not read or write
|
|
1018
1018
|
* sendTurnId/turnInFlight/suppressProviderIdle/turnClosed/retry/reroll and must never emit a
|
|
@@ -1030,7 +1030,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1030
1030
|
* - At-least-once with explicit ambiguity: one POST, then (only on ambiguity) ONE bounded
|
|
1031
1031
|
* exact-id reconcile; a positive persistence is NEVER re-POSTed.
|
|
1032
1032
|
*
|
|
1033
|
-
*
|
|
1033
|
+
* Phase A reconciliation — the result speaks the shared DeliverResult vocabulary:
|
|
1034
1034
|
* persisted/ambiguous ids return as `awaitingConsumptionIds` (their settlement completes later
|
|
1035
1035
|
* through ctx.settleMidTurnConsumed / ctx.reofferMidTurnIds — persistence is not delivery);
|
|
1036
1036
|
* everything NOT accepted (lane off, boundary-only, cap overflow, pre-POST fencing loss) is
|
|
@@ -1115,7 +1115,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1115
1115
|
// Force-tracked even at the ledger cap: the cap gates new POSITIVE persists (backpressure to
|
|
1116
1116
|
// the normal queue), never the RESOLVABILITY of an already-attempted POST — an untracked
|
|
1117
1117
|
// unknown batch could never be resolved by any ledger exit while this contract reports it as
|
|
1118
|
-
// held (silent orphan under exactly the long-turn + one-timeout scenario
|
|
1118
|
+
// held (silent orphan under exactly the long-turn + one-timeout scenario exists for).
|
|
1119
1119
|
this.softSteer.noteBatch(messageID, includedIds, sessionId, { force: true, inputObserver: ctx?.inputObserver });
|
|
1120
1120
|
// Same swap-inside-the-POST-window fence as the persisted path: a batch noted onto a dead
|
|
1121
1121
|
// session can never be resolved there (the same-session consumption door and the
|
|
@@ -1131,7 +1131,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1131
1131
|
// The spawn config the adapter was launched with (meta.config is the RunnerSpawnConfig handed
|
|
1132
1132
|
// to spawn()); the steer policy reads its providerConfig — never a provider-name branch.
|
|
1133
1133
|
// Same shape as codex's spawnProviderConfig (kept per-adapter, matching the landed precedent).
|
|
1134
|
-
//
|
|
1134
|
+
// the absent-config default is "steer" (default-on); an explicit "defer" is the
|
|
1135
1135
|
// per-agent kill switch, resolved through the shared seam like codex's "steer" default.
|
|
1136
1136
|
private softSteerPolicyFor(process: ManagedProcess): "steer" | "defer" {
|
|
1137
1137
|
const config = (process.meta?.config as { providerConfig?: ProviderConfig } | undefined)?.providerConfig;
|
|
@@ -1159,7 +1159,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
1161
|
/**
|
|
1162
|
-
*
|
|
1162
|
+
* Phase B — the bounded idle-wake fallback: the provider just went idle with
|
|
1163
1163
|
* positively-persisted-but-unconsumed soft batches (mid-run reload missed them — the live
|
|
1164
1164
|
* at-stop strand, or an unknown-outcome batch). Arm EXACTLY ONE content-free wake turn through
|
|
1165
1165
|
* turnQueue (a NORMAL send() turn: real busy/idle pair, full retry semantics — no second turn
|
|
@@ -1198,16 +1198,16 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1198
1198
|
}
|
|
1199
1199
|
|
|
1200
1200
|
/**
|
|
1201
|
-
*
|
|
1202
|
-
* active one. This REUSES the proven
|
|
1201
|
+
* self-resume clear-inject support: mint a fresh, history-less session and make it the
|
|
1202
|
+
* active one. This REUSES the proven re-roll seam (the same `POST /session` via
|
|
1203
1203
|
* `createSession`, which explicitly drops conversation history, and the same in-place
|
|
1204
1204
|
* `process.meta.sessionId` swap so every reader — send/interrupt/shutdown and the SSE doors'
|
|
1205
1205
|
* foreign-session filter — picks the new id up with no separate bookkeeping); it does not invent
|
|
1206
1206
|
* a second session-minting mechanism.
|
|
1207
1207
|
*
|
|
1208
|
-
* Ordering/composition with the turn state machine
|
|
1208
|
+
* Ordering/composition with the turn state machine is ENFORCED, not
|
|
1209
1209
|
* assumed (round 1 HIGH): the whole swap runs inside `enqueueTurn`, so a clear that arrives while
|
|
1210
|
-
* a turn is mid-flight — which DOES happen, since the model calls
|
|
1210
|
+
* a turn is mid-flight — which DOES happen, since the model calls humwick_compact_and_resume from
|
|
1211
1211
|
* within its own synchronous `/message` turn and dispatchCommand gives agent.clearContext no
|
|
1212
1212
|
* provider-input ticket — waits for that turn to settle and completes it wholly on the session
|
|
1213
1213
|
* it started with before minting anything. The reactive continuation inject is separately
|
|
@@ -1216,9 +1216,9 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1216
1216
|
* closed turn left `turnClosed` set and `suppressProviderIdle` sticky per round-5 MED-1; those
|
|
1217
1217
|
* fields are deliberately NOT touched — resetting them could double-close an already-closed turn,
|
|
1218
1218
|
* and any trailing SSE event belonging to the PRE-clear turn carries the OLD sessionID, which the
|
|
1219
|
-
*
|
|
1219
|
+
* foreign-session filter now drops against the NEW active id. Same reasoning leaves
|
|
1220
1220
|
* `messageRoles`/`emittedTextParts`/`emittedToolSteps` alone: ids are server-global on this one
|
|
1221
|
-
* process and pruned to OPENCODE_EVENT_STATE_LIMIT regardless (the
|
|
1221
|
+
* process and pruned to OPENCODE_EVENT_STATE_LIMIT regardless (the re-roll leaves them
|
|
1222
1222
|
* too). The SSE subscription is per-server, not per-session, so the new session stays fully
|
|
1223
1223
|
* observable through the existing wiring.
|
|
1224
1224
|
*
|
|
@@ -1237,12 +1237,12 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1237
1237
|
const client = process.meta?.client as OpenCodeClient | undefined;
|
|
1238
1238
|
const previousSessionId = stringValue(process.meta?.sessionId);
|
|
1239
1239
|
if (!client || !previousSessionId) throw new Error("OpenCode session is not ready");
|
|
1240
|
-
//
|
|
1240
|
+
// the session swap is also an attachment-generation rotation: any open event-driven
|
|
1241
1241
|
// terminal turn belongs to the OLD session (its matching idle carries the old sessionID and
|
|
1242
1242
|
// would be dropped by the foreign-session filter, stranding runner-core busy). Close it on
|
|
1243
1243
|
// the old session's truth before the swap; the orchestrator's generation check kills the
|
|
1244
1244
|
// stale guest TUI on its next input/stream contact.
|
|
1245
|
-
//
|
|
1245
|
+
// round 2 (review D) — flush a buffered terminal prompt under the OLD session
|
|
1246
1246
|
// pseudo-turn before the swap (the operator typed there; the buffer must not leak into
|
|
1247
1247
|
// the new session), then close any open terminal turn as in send() takeover.
|
|
1248
1248
|
this.flushBufferedPrompt(previousSessionId || undefined);
|
|
@@ -1250,7 +1250,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1250
1250
|
this.statusCb({ status: "busy", reason: "provider-turn", timeline: { status: "clearing-context", timestamp: Date.now() } });
|
|
1251
1251
|
let newSessionId: string;
|
|
1252
1252
|
try {
|
|
1253
|
-
newSessionId = await client.createSession(stringValue(process.meta?.sessionTitle) ?? "
|
|
1253
|
+
newSessionId = await client.createSession(stringValue(process.meta?.sessionTitle) ?? "humwick");
|
|
1254
1254
|
} catch (error) {
|
|
1255
1255
|
// The old session is still intact and active — restore idle so the failed command doesn't
|
|
1256
1256
|
// strand a provider-turn busy hold (same recovery shape as Codex's clearContext).
|
|
@@ -1260,7 +1260,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1260
1260
|
const clearedAt = Date.now();
|
|
1261
1261
|
if (process.meta) {
|
|
1262
1262
|
process.meta.sessionId = newSessionId;
|
|
1263
|
-
//
|
|
1263
|
+
// Phase B — soft-steer fencing: batches accepted-but-unconsumed on the replaced
|
|
1264
1264
|
// session can never be consumed on the fresh one; their relay ids go BACK to the runner's
|
|
1265
1265
|
// normal queue (the settlement callback fires inside sessionReplaced — never reposted into
|
|
1266
1266
|
// the cleared session).
|
|
@@ -1278,12 +1278,12 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1278
1278
|
}
|
|
1279
1279
|
|
|
1280
1280
|
/**
|
|
1281
|
-
*
|
|
1282
|
-
* (mistakenly believed unavailable when
|
|
1281
|
+
* native self-resume compaction via OpenCode's own `POST /session/{id}/compact`
|
|
1282
|
+
* (mistakenly believed unavailable when shipped clear-inject only). Reuses the SAME
|
|
1283
1283
|
* turnQueue FIFO serializer clearContext() established: `agent.compact` is dispatched
|
|
1284
1284
|
* fire-and-forget with no provider-input ticket (like `agent.clearContext`), and OpenCode's
|
|
1285
1285
|
* `/message` POST is synchronous, so a compact triggered from inside the model's own turn
|
|
1286
|
-
* (
|
|
1286
|
+
* (humwick_compact_and_resume) genuinely races an in-flight send() without it. See the
|
|
1287
1287
|
* `turnQueue` field doc for the full race shape this closes off.
|
|
1288
1288
|
*/
|
|
1289
1289
|
async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
|
|
@@ -1299,7 +1299,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1299
1299
|
* (no `couplesClearInjectContinuation`, same as its clear-inject path today), so the relay's own
|
|
1300
1300
|
* reactive continuation inject (`enqueueContinuationInjectionAfterSelfResume`, src/self-resume.ts)
|
|
1301
1301
|
* delivers the SAME continuation as a follow-up `agent.injectContext` once this command succeeds
|
|
1302
|
-
* — the identical path clear-inject already relies on for OpenCode (see the
|
|
1302
|
+
* — the identical path clear-inject already relies on for OpenCode (see the test "the first
|
|
1303
1303
|
* post-clear delivery lands in the NEW session ... through the same deliver()/send() path").
|
|
1304
1304
|
*/
|
|
1305
1305
|
private async performCompact(process: ManagedProcess, _opts?: { instructions?: string }): Promise<Record<string, unknown>> {
|
|
@@ -1318,7 +1318,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1318
1318
|
const compactedAt = Date.now();
|
|
1319
1319
|
const currentContext = process.meta?.context as ContextState | undefined;
|
|
1320
1320
|
if (process.meta) {
|
|
1321
|
-
//
|
|
1321
|
+
// round 2 — stamp unconditionally, mirroring swapSessionForClear's shape: a compact
|
|
1322
1322
|
// that lands before any context state was ever reported (e.g. immediately after spawn) must
|
|
1323
1323
|
// still record the compaction rather than silently no-oping, exactly like clearContext's
|
|
1324
1324
|
// always-fresh stamp below. Falls back to clearContext's own baseline defaults (utilization
|
|
@@ -1338,13 +1338,13 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1338
1338
|
}
|
|
1339
1339
|
|
|
1340
1340
|
async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
|
|
1341
|
-
//
|
|
1341
|
+
// Phase B — anything accepted-but-unconsumed goes back to the runner's normal queue via
|
|
1342
1342
|
// the settlement callback (inside reofferAll) before teardown; the ledger itself never drops
|
|
1343
1343
|
// an id silently — a failed callback re-inserts the entries for the next exit.
|
|
1344
1344
|
const softSteerLeftovers = await this.softSteer.reofferAll();
|
|
1345
1345
|
if (softSteerLeftovers.length > 0) logger.info("opencode", `soft-steer: shutdown reoffering ${softSteerLeftovers.length} relay id(s)`);
|
|
1346
1346
|
this.softSteerProcess = undefined;
|
|
1347
|
-
//
|
|
1347
|
+
// round 1 review (F2/F3) — session end: force any still-pending debounced token-usage
|
|
1348
1348
|
// report out now, rather than losing it to a timer that never gets to fire. Round 2 (MED-1)
|
|
1349
1349
|
// — awaited, not fire-and-forget: this covers the controlled-teardown paths that call
|
|
1350
1350
|
// adapter.shutdown() directly (restartProvider, shutdownProvider) without going through
|
|
@@ -1360,7 +1360,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1360
1360
|
/**
|
|
1361
1361
|
* Exported behavior through the adapter so fixture contracts can pin the v1.18.21 event shapes.
|
|
1362
1362
|
*
|
|
1363
|
-
* Reproduced live against a real server
|
|
1363
|
+
* Reproduced live against a real server: the wire shapes here were captured directly
|
|
1364
1364
|
* off `/event`, not inferred from docs. Two things the previous implementation got wrong:
|
|
1365
1365
|
*
|
|
1366
1366
|
* 1. `session.status` carries a nested `{ status: { type: "busy" | "idle" } }`, not a bare
|
|
@@ -1376,12 +1376,12 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1376
1376
|
handleEvent(event: OpenCodeServerEvent, process?: ManagedProcess): void {
|
|
1377
1377
|
const properties = event.properties ?? {};
|
|
1378
1378
|
if (event.type === "session.status") {
|
|
1379
|
-
//
|
|
1379
|
+
// a foreign-session event must never affect this adapter's turn/idle state, full
|
|
1380
1380
|
// stop, regardless of suppression state. This is what makes it safe for the two doors below
|
|
1381
1381
|
// to stop enforcing PERMANENT suppression once `turnAbandoned`: an unfiltered foreign child
|
|
1382
1382
|
// session's busy/idle was exactly the hazard round 7 made suppression permanent to guard
|
|
1383
1383
|
// against, and this filter closes it directly instead.
|
|
1384
|
-
//
|
|
1384
|
+
// round 1 (review C) — FAIL CLOSED: whenever the active session id is known (a live
|
|
1385
1385
|
// spawn always carries one), an event WITHOUT a sessionID is rejected too — a session-
|
|
1386
1386
|
// less status cannot be positively attributed to the active conversation, and acting on
|
|
1387
1387
|
// it could open/close a turn for a dead session. Test-only call sites that pass no
|
|
@@ -1397,26 +1397,26 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1397
1397
|
}
|
|
1398
1398
|
if (status === "busy") {
|
|
1399
1399
|
// send() owns the turn boundary while a headless delivery is in flight — its own
|
|
1400
|
-
// explicit busy (with its minted id) is authoritative, exactly as before
|
|
1400
|
+
// explicit busy (with its minted id) is authoritative, exactly as before. Only
|
|
1401
1401
|
// busy events OUTSIDE send() can open a terminal-origin turn below.
|
|
1402
1402
|
if (this.turnInFlight) return;
|
|
1403
1403
|
// A terminal turn is already open — its matching idle owns the close; a duplicate
|
|
1404
1404
|
// busy must not re-mint.
|
|
1405
1405
|
if (this.eventTurnId) return;
|
|
1406
|
-
//
|
|
1406
|
+
// re-arm the close guard: this real busy is evidence the turn (from
|
|
1407
1407
|
// `send()`'s perspective, already force-closed to idle by the abandonment path) is
|
|
1408
1408
|
// actually still running, so the eventual matching idle must be allowed to close it
|
|
1409
1409
|
// again.
|
|
1410
1410
|
if (this.turnAbandoned) this.turnClosed = false;
|
|
1411
|
-
//
|
|
1411
|
+
// round 1 (review D) — round-7 echo guard, restored to PERMANENT and made
|
|
1412
1412
|
// POSITIVE (no time heuristic): once any headless turn has run on this process, a busy
|
|
1413
1413
|
// is admitted ONLY when a fresh terminal user prompt armed `pendingTerminalPrompt` (the
|
|
1414
1414
|
// TUI's POST creates the user message before the turn's busy flows). The buffered
|
|
1415
1415
|
// own-turn echo has no fresh user message by construction — at ANY delay — so it stays
|
|
1416
|
-
// swallowed. (
|
|
1416
|
+
// swallowed. (abandonment keeps this door open — those server events are the only
|
|
1417
1417
|
// remaining proof of the turn's real state.)
|
|
1418
1418
|
if (!this.turnAbandoned && this.headlessTurnSeen && !this.pendingTerminalPrompt) return;
|
|
1419
|
-
//
|
|
1419
|
+
// round 1 (review D) — a genuinely new, terminal-origin turn: mint its provider turn id and hand it
|
|
1420
1420
|
// to runner-core (which keys currentTurnId off this busy), then associate every user/
|
|
1421
1421
|
// assistant/tool message id of the turn with it (see activeTurnId()) until the matching
|
|
1422
1422
|
// idle closes it. Un-stick the idle suppression so this turn's own idle can pass.
|
|
@@ -1425,7 +1425,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1425
1425
|
this.eventTurnText = [];
|
|
1426
1426
|
this.eventTurnReasoning = new Map();
|
|
1427
1427
|
this.suppressProviderIdle = false;
|
|
1428
|
-
//
|
|
1428
|
+
// round 2 (review D) — a prompt buffered by the [user message → user part →
|
|
1429
1429
|
// busy] ordering is emitted NOW under the freshly minted id, so prompt → trace →
|
|
1430
1430
|
// final-response all share one opencode-turn-* id.
|
|
1431
1431
|
this.flushBufferedPrompt(this.eventTurnId);
|
|
@@ -1434,16 +1434,16 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1434
1434
|
if (status === "idle") {
|
|
1435
1435
|
// send() closes its own turn deterministically (closeSuppressedTurn / failTurn / the
|
|
1436
1436
|
// finally) — an SSE idle while a headless delivery is in flight stays swallowed, as
|
|
1437
|
-
// since
|
|
1437
|
+
// since round 2.
|
|
1438
1438
|
if (this.turnInFlight) return;
|
|
1439
|
-
//
|
|
1439
|
+
// round 2 (review D) — bounded fallback: an active-session idle with a prompt
|
|
1440
1440
|
// still buffered means the turn ended without us seeing its busy (or it was itself an
|
|
1441
1441
|
// echo). Flush under the session pseudo-turn so the operator's input is never stranded
|
|
1442
1442
|
// in the buffer; RunnerCore's injection dedup guards the chat-echo case.
|
|
1443
1443
|
if (this.bufferedTerminalPrompt && !this.eventTurnId) {
|
|
1444
1444
|
this.flushBufferedPrompt(stringValue(process?.meta?.sessionId) || undefined);
|
|
1445
1445
|
}
|
|
1446
|
-
//
|
|
1446
|
+
// the matching close of an event-driven (terminal) turn: emit the turn-final
|
|
1447
1447
|
// response accumulated from the turn's live assistant text parts (mirroring send()'s
|
|
1448
1448
|
// emitTurnFinalResponse → close ordering) and then the idle edge that closes
|
|
1449
1449
|
// runner-core's turn.
|
|
@@ -1451,17 +1451,17 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1451
1451
|
this.closeEventTurn();
|
|
1452
1452
|
return;
|
|
1453
1453
|
}
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1454
|
+
// round 2 (F4) — a transient idle from a failed attempt, still mid-retry-loop.
|
|
1455
|
+
// round 7 review (MED-2/LOW-1) — round 6's one-shot reopen (lower the flag on the
|
|
1456
1456
|
// FIRST post-close idle, keyed on `turnInFlight`) let a SECOND idle in the same trailing-
|
|
1457
1457
|
// echo window pass through and double-close. There is no legitimate turn boundary this SSE
|
|
1458
1458
|
// door needs to report that `send()`/`closeSuppressedTurn`/`failTurn` don't already emit
|
|
1459
1459
|
// explicitly for a DETERMINISTIC outcome (see the field comment) — so there is no reopen on
|
|
1460
1460
|
// that path: suppression stays permanent for any turn `send()` itself closed knowingly.
|
|
1461
|
-
//
|
|
1461
|
+
// EXCEPT the terminal-origin path above: an idle outside send with no event
|
|
1462
1462
|
// turn open is still an echo/stray (the busy that minted a turn would have set one), so
|
|
1463
1463
|
// the permanent suppression holds exactly as before for chat turns.
|
|
1464
|
-
//
|
|
1464
|
+
// the one exception is the same `turnAbandoned` escape hatch as the busy door
|
|
1465
1465
|
// above; `turnClosed` (not `suppressProviderIdle`) is what still prevents a double-close
|
|
1466
1466
|
// here — either the abandonment path's own forced idle already closed it (this real idle
|
|
1467
1467
|
// is then a no-op confirmation), or a real busy re-armed it above and this is the genuine
|
|
@@ -1489,15 +1489,15 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1489
1489
|
this.messageRoles.set(id, role);
|
|
1490
1490
|
pruneToLimit(this.messageRoles, OPENCODE_EVENT_STATE_LIMIT);
|
|
1491
1491
|
}
|
|
1492
|
-
//
|
|
1493
|
-
// `tokens` totals the synchronous POST resolves with (send
|
|
1492
|
+
// the SSE echo of a finalized assistant message carries the SAME per-message
|
|
1493
|
+
// `tokens` totals the synchronous POST resolves with (send's report), but on a long
|
|
1494
1494
|
// turn that POST can die client-side or stay in-flight forever, so usage reported ONLY from
|
|
1495
|
-
// it never lands
|
|
1495
|
+
// it never lands. Report from this door instead: it fires
|
|
1496
1496
|
// DURING the turn regardless of POST survival, mirroring how Codex consumes its
|
|
1497
1497
|
// `thread/tokenUsage/updated` push. Idempotent with the POST path — last write wins and both
|
|
1498
1498
|
// doors feed the same openCodeContextState no-signal rules (0/unparsable → no-op), so neither
|
|
1499
|
-
// can regress a good reading — and same-session-filtered like
|
|
1500
|
-
//
|
|
1499
|
+
// can regress a good reading — and same-session-filtered like the session.status door.
|
|
1500
|
+
// round 1 (review C) — FAIL CLOSED: with the active session id known, a usage echo
|
|
1501
1501
|
// WITHOUT a sessionID is rejected too (an unattributable reading must never overwrite the
|
|
1502
1502
|
// active session's context state). Test-only process-less call sites keep the lenient path.
|
|
1503
1503
|
const sessionID = stringValue(properties.sessionID);
|
|
@@ -1508,9 +1508,9 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1508
1508
|
const model = stringValue(process.meta?.model);
|
|
1509
1509
|
this.reportContextUsage(process, model, tokens);
|
|
1510
1510
|
}
|
|
1511
|
-
//
|
|
1511
|
+
// same door, reused for the spine report: model/createdAt prefer the message's
|
|
1512
1512
|
// own info (round 1 review F5/F6) over the session's configured model / receipt time.
|
|
1513
|
-
//
|
|
1513
|
+
// outside the context guard so a finished message without counts reports unknown.
|
|
1514
1514
|
if (process && role === "assistant" && id) captureOpenCodeApiUsage(this.apiBudgets.get(process), info, sessionID ?? activeSessionId, stringValue(process.meta?.model), id);
|
|
1515
1515
|
if (process && role === "assistant" && id && !sessionUnattributable) {
|
|
1516
1516
|
scheduleOpenCodeTokenUsage(this.tokenUsagePoster, {
|
|
@@ -1523,7 +1523,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1523
1523
|
finished: openCodeMessageFinished(info),
|
|
1524
1524
|
});
|
|
1525
1525
|
}
|
|
1526
|
-
//
|
|
1526
|
+
// round 1 (review D) — ARM the terminal-turn gate: a FIRST-SEEN user message on the
|
|
1527
1527
|
// ACTIVE session observed outside send() is the event-source proof that a human typed into
|
|
1528
1528
|
// the attached TUI. The next active-session busy is then admitted as terminal-origin even
|
|
1529
1529
|
// after a headless close (whose own buffered echo carries no fresh user message — its user
|
|
@@ -1531,7 +1531,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1531
1531
|
// from arming). Fail-closed session-filtered like the usage read above, never armed while
|
|
1532
1532
|
// send() is in flight (the chat delivery's own user echo must not open a terminal turn
|
|
1533
1533
|
// behind send()'s back) nor while a terminal turn is already open.
|
|
1534
|
-
//
|
|
1534
|
+
// Phase B — soft-steer consumption door: an assistant message whose info.parentID
|
|
1535
1535
|
// equals a soft batch's user row id is positive proof the active loop CONSUMED that batch
|
|
1536
1536
|
// (the loop reloads full messages each step and re-parents subsequent assistant steps to
|
|
1537
1537
|
// the newest user row — live-verified on pinned 1.18.21). FIFO settle: the matched batch
|
|
@@ -1550,19 +1550,19 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1550
1550
|
const firstSeen = !this.seenUserMessageIds.has(id);
|
|
1551
1551
|
this.seenUserMessageIds.add(id);
|
|
1552
1552
|
pruneToLimit(this.seenUserMessageIds, OPENCODE_EVENT_STATE_LIMIT);
|
|
1553
|
-
//
|
|
1553
|
+
// Phase B — relay's OWN soft-steer user rows never arm the terminal-origin gate:
|
|
1554
1554
|
// they are first-seen user messages OUTSIDE send() (the whole point of the lane), and
|
|
1555
1555
|
// arming here would misclassify the wake turn (or any user's next turn) as terminal.
|
|
1556
1556
|
const terminalOrigin = firstSeen && !this.turnInFlight && !this.eventTurnId && !sessionUnattributable && !this.softSteer.isTrackedUserId(id);
|
|
1557
1557
|
if (terminalOrigin) this.pendingTerminalPrompt = true;
|
|
1558
|
-
if (terminalOrigin) adoptOpenCodeTerminalModel(process?.meta, info); //
|
|
1558
|
+
if (terminalOrigin) adoptOpenCodeTerminalModel(process?.meta, info); // keep a model picked in the TUI
|
|
1559
1559
|
}
|
|
1560
1560
|
return;
|
|
1561
1561
|
}
|
|
1562
1562
|
if (event.type === "message.part.updated") {
|
|
1563
1563
|
const part = isRecord(properties.part) ? properties.part : undefined;
|
|
1564
1564
|
if (!part) return;
|
|
1565
|
-
//
|
|
1565
|
+
// acceptance door: a persisted user text part whose text equals the armed
|
|
1566
1566
|
// context-carrying outbound prompt is positive proof OpenCode ACCEPTED it. Consuming here
|
|
1567
1567
|
// marks the first-turn markers MID-TURN — seconds after dispatch — so a first turn that
|
|
1568
1568
|
// later runs past the whole-turn deadline can never leave them false (the await-then-mark
|
|
@@ -1570,33 +1570,33 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1570
1570
|
// text identity are fail-closed inside consumeOpenCodeContextAcceptance: a stale-session
|
|
1571
1571
|
// echo or an operator-typed prompt never marks.
|
|
1572
1572
|
consumeOpenCodeContextAcceptance(process, stringValue(properties.sessionID), stringValue(part.text));
|
|
1573
|
-
//
|
|
1573
|
+
// ANY part on an assistant message is proof the model produced something, including
|
|
1574
1574
|
// the tool parts this adapter does not mirror (their internal shape is not pinned, so they are
|
|
1575
1575
|
// reported as bare activity rather than published as a half-understood trace step). Without
|
|
1576
1576
|
// this a tools-only OpenCode turn emits no session events at all and reads as empty.
|
|
1577
|
-
//
|
|
1577
|
+
// round 3 review (HIGH-1r) — EXCEPT a bare step-start/step-finish bookkeeping part,
|
|
1578
1578
|
// which the server emits on every turn regardless of outcome. `isOutputPart` is the same
|
|
1579
1579
|
// predicate `completionProducedOutput` uses on the POST-body door (HIGH-1); this is the SSE
|
|
1580
1580
|
// door, and both must agree or the issue's own incident (a step-start-only turn) still
|
|
1581
|
-
// discharges
|
|
1581
|
+
// discharges the escalation through whichever door fires first. `noteOutput` is called
|
|
1582
1582
|
// with no event here (onTurnActivity takes no args), so MED-5's `diagnostic` filter cannot
|
|
1583
1583
|
// see this path at all — it must be excluded at the source instead.
|
|
1584
1584
|
if (isOutputPart(part) && this.messageRoles.get(stringValue(part.messageID) ?? "") === "assistant") this.turnActivityCb();
|
|
1585
|
-
//
|
|
1585
|
+
// round 2 (review D) — no-busy-seen fallback, bounded: when trace activity
|
|
1586
1586
|
// (tool/narration/reasoning) arrives while a terminal prompt is still buffered and no
|
|
1587
1587
|
// terminal turn ever opened, flush the prompt FIRST under the same session pseudo-turn
|
|
1588
1588
|
// the trace will use, keeping prompt→trace grouped instead of stranding the buffer.
|
|
1589
1589
|
if (this.bufferedTerminalPrompt && !this.eventTurnId && !this.turnInFlight) {
|
|
1590
1590
|
this.flushBufferedPrompt(stringValue(process?.meta?.sessionId) || undefined);
|
|
1591
1591
|
}
|
|
1592
|
-
//
|
|
1592
|
+
// surface tool calls in chat/timeline the way Codex does: decode the ToolPart and
|
|
1593
1593
|
// emit a running/completed/failed/cancelled session step (upserted in place by stepId). This
|
|
1594
1594
|
// must run before the text-only early-return below, which used to drop every non-text part.
|
|
1595
1595
|
if (stringValue(part.type) === "tool") {
|
|
1596
1596
|
this.emitToolStep(part, process);
|
|
1597
1597
|
return;
|
|
1598
1598
|
}
|
|
1599
|
-
//
|
|
1599
|
+
// surface the model's thinking as a `reasoning` trace row the way Codex/Claude do:
|
|
1600
1600
|
// decode the ReasoningPart and emit it (upserted in place by stepId = stable part id). Runs
|
|
1601
1601
|
// before the text-only early-return below, which is also what used to drop these parts.
|
|
1602
1602
|
if (stringValue(part.type) === "reasoning") {
|
|
@@ -1609,11 +1609,11 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1609
1609
|
const text = stringValue(part.text);
|
|
1610
1610
|
const finalized = isRecord(part.time) && part.time.end !== undefined;
|
|
1611
1611
|
if (!partId || !messageId || !text || !finalized) return;
|
|
1612
|
-
//
|
|
1612
|
+
// a finalized role:user text part on the ACTIVE server session is a prompt typed
|
|
1613
1613
|
// into an attached web-terminal TUI: mirror it into chat as a terminal-origin prompt.
|
|
1614
1614
|
// RunnerCore's recordInjectedPrompt already consumes the echo of every Relay-injected
|
|
1615
1615
|
// prompt (chat deliveries), so terminal prompts appear once while dashboard-injected
|
|
1616
|
-
// prompts do not duplicate.
|
|
1616
|
+
// prompts do not duplicate. Round 1 (review C) — FAIL CLOSED: with the active
|
|
1617
1617
|
// session id known, a part WITHOUT a sessionID is rejected too — after a clearContext()
|
|
1618
1618
|
// or retry re-roll an unattributable/old-session prompt belongs (at best) to a dead
|
|
1619
1619
|
// conversation, and the generation boundary kills that stale TUI anyway.
|
|
@@ -1624,7 +1624,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1624
1624
|
if (this.emittedTextParts.has(partId)) return;
|
|
1625
1625
|
this.emittedTextParts.add(partId);
|
|
1626
1626
|
pruneToLimit(this.emittedTextParts, OPENCODE_EVENT_STATE_LIMIT);
|
|
1627
|
-
//
|
|
1627
|
+
// round 2 (review D) — turn-id ordering. While send is in flight, emit
|
|
1628
1628
|
// immediately under send()'s id (RunnerCore dedups the chat-injection echo). While a
|
|
1629
1629
|
// terminal turn is open, emit under its minted id. Otherwise — the [user message →
|
|
1630
1630
|
// user part → busy] wire ordering, where the TUI's POST creates the user message
|
|
@@ -1651,7 +1651,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1651
1651
|
if (this.emittedTextParts.has(partId)) return;
|
|
1652
1652
|
this.emittedTextParts.add(partId);
|
|
1653
1653
|
pruneToLimit(this.emittedTextParts, OPENCODE_EVENT_STATE_LIMIT);
|
|
1654
|
-
//
|
|
1654
|
+
// while an event-driven terminal turn is open, its finalized assistant text also
|
|
1655
1655
|
// accumulates toward the turn-final response emitted by closeEventTurn().
|
|
1656
1656
|
if (this.eventTurnId) this.eventTurnText.push(text.trim());
|
|
1657
1657
|
const turnId = this.activeTurnId() ?? stringValue(process?.meta?.sessionId);
|
|
@@ -1660,7 +1660,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1660
1660
|
}
|
|
1661
1661
|
|
|
1662
1662
|
/**
|
|
1663
|
-
*
|
|
1663
|
+
* decode one ReasoningPart and emit its trace row, with in-place upsert semantics: a
|
|
1664
1664
|
* reasoning part GROWS as the model thinks (SSE emits the part repeatedly, each frame carrying
|
|
1665
1665
|
* the FULL text so far), so a frame for an already-emitted part id re-emits the current full
|
|
1666
1666
|
* body — the server upserts it into the same row by stepId. Only assistant-message reasoning
|
|
@@ -1682,7 +1682,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1682
1682
|
if (prev === step.body) return;
|
|
1683
1683
|
this.emittedReasoningParts.set(step.stepId, step.body);
|
|
1684
1684
|
pruneToLimit(this.emittedReasoningParts, OPENCODE_EVENT_STATE_LIMIT);
|
|
1685
|
-
//
|
|
1685
|
+
// record the latest snapshot per part for the event-turn close below. Unconditional
|
|
1686
1686
|
// (not just on emit): the close's reconcile must see the newest body even for frames that
|
|
1687
1687
|
// were live-emitted, so it can compare per-part and emit nothing already traced.
|
|
1688
1688
|
if (this.eventTurnId) this.eventTurnReasoning.set(step.stepId, step.body);
|
|
@@ -1691,14 +1691,14 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1691
1691
|
}
|
|
1692
1692
|
|
|
1693
1693
|
/**
|
|
1694
|
-
*
|
|
1694
|
+
* decode one ToolPart and emit its session step, with dedup + terminal-lock: a
|
|
1695
1695
|
* duplicate frame or a reordered update (a late `running` after `completed`) is dropped rather
|
|
1696
1696
|
* than regressing or double-appending the row. Only assistant-message tool parts are surfaced.
|
|
1697
1697
|
* `stepId` = the stable part id, so the server upserts running→completed IN PLACE (one row),
|
|
1698
1698
|
* exactly like Codex's app-server item-id upsert.
|
|
1699
1699
|
*/
|
|
1700
1700
|
private emitToolStep(part: Record<string, unknown>, process?: ManagedProcess): void {
|
|
1701
|
-
//
|
|
1701
|
+
// review (LOW) — fail CLOSED: a tool part lacking a messageID (so its role can't be
|
|
1702
1702
|
// confirmed assistant) is dropped, not surfaced.
|
|
1703
1703
|
const messageId = stringValue(part.messageID);
|
|
1704
1704
|
if (!messageId || this.messageRoles.get(messageId) !== "assistant") return;
|
|
@@ -1712,18 +1712,18 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1712
1712
|
if (step.params !== undefined) this.toolStepParams.set(step.stepId, step.params);
|
|
1713
1713
|
const params = step.params !== undefined ? step.params : this.toolStepParams.get(step.stepId);
|
|
1714
1714
|
pruneToLimit(this.toolStepParams, OPENCODE_EVENT_STATE_LIMIT);
|
|
1715
|
-
//
|
|
1715
|
+
// tool steps attach to the live turn id (send's while a headless delivery runs,
|
|
1716
1716
|
// the event-driven terminal turn otherwise), never to a stale prior headless turn id.
|
|
1717
1717
|
this.sessionEventCb(openCodeToolSessionEvent({ ...step, params }, this.activeTurnId() ?? stringValue(process?.meta?.sessionId)));
|
|
1718
1718
|
}
|
|
1719
1719
|
|
|
1720
1720
|
/**
|
|
1721
|
-
*
|
|
1721
|
+
* mark the turn-final assistant text as a `response` (not `narration`), so the runner's
|
|
1722
1722
|
* Final Response capture identifies it, auto-forwards an OpenCode child's report to its
|
|
1723
1723
|
* coordinator, and the chat styles it apart from reasoning — parity with Claude/Codex, which
|
|
1724
1724
|
* OpenCode lacked entirely (everything read as thinking, nothing auto-forwarded).
|
|
1725
1725
|
*
|
|
1726
|
-
*
|
|
1726
|
+
* also emits the turn-final `reasoning` rows from the SAME completed assistant message
|
|
1727
1727
|
* (`completion.reasoningSteps`, via `openCodeTurnFinalEvents` in opencode-tool-events.ts), so
|
|
1728
1728
|
* the model's thinking survives in the trace even when the SSE reasoning frames were never
|
|
1729
1729
|
* observed (a POST-only reader, a dropped subscription, a turn whose frames raced the close):
|
|
@@ -1734,7 +1734,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1734
1734
|
* emitted-body record the SSE door shares, scoped to part ids — never a global body pool.
|
|
1735
1735
|
*
|
|
1736
1736
|
* The text is read from the synchronous POST's completed assistant message (`completion.text`,
|
|
1737
|
-
* ground truth
|
|
1737
|
+
* ground truth) rather than reconstructed from SSE. `confidence:"definitive"` because
|
|
1738
1738
|
* the POST returning IS the provider-native turn-end edge; `final:true` is what makes it a
|
|
1739
1739
|
* turn-final capture downstream. The same text already streamed live as `narration` (per text
|
|
1740
1740
|
* part); the dashboard's per-turn narration/response dedup (keyed on the shared `turnId`)
|
|
@@ -1747,7 +1747,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1747
1747
|
for (const event of openCodeTurnFinalEvents(completion, turnId, emittedBodies)) this.emitReasoningFinal(event);
|
|
1748
1748
|
}
|
|
1749
1749
|
|
|
1750
|
-
/**
|
|
1750
|
+
/** ledger write for a turn-final reasoning row, shared by both turn-close seams. */
|
|
1751
1751
|
private emitReasoningFinal(event: ProviderSessionEvent): void {
|
|
1752
1752
|
if (event.stepId) {
|
|
1753
1753
|
this.emittedReasoningParts.set(event.stepId, event.body);
|
|
@@ -1757,9 +1757,9 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1757
1757
|
}
|
|
1758
1758
|
|
|
1759
1759
|
/**
|
|
1760
|
-
*
|
|
1760
|
+
* turn-ending PROVIDER errors (e.g. an OpenRouter 429 on a throttled preview model)
|
|
1761
1761
|
* are read off THIS synchronous POST's response body (`completion.error`, via
|
|
1762
|
-
* `openCodeCompletion`), not off a separate SSE listener.
|
|
1762
|
+
* `openCodeCompletion`), not off a separate SSE listener. Already established that this
|
|
1763
1763
|
* response is ground truth and deterministic (no dependency on an SSE frame's timing); reusing
|
|
1764
1764
|
* that one seam here — instead of also inspecting `message.updated`'s `info.error` — keeps
|
|
1765
1765
|
* OpenCode's turn-outcome detection in exactly one place, as the standing one-seam rule in this
|
|
@@ -1772,13 +1772,13 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1772
1772
|
*/
|
|
1773
1773
|
/**
|
|
1774
1774
|
* @returns `"ok"` when the turn ended in output (or a positively-parsed empty completion that
|
|
1775
|
-
* is not itself a failure —
|
|
1776
|
-
* called — i.e. the message provoked a terminal, unrecoverable failure — and `"held"`
|
|
1775
|
+
* is not itself a failure — the normal empty-turn path), `"failed"` only when `failTurn` was
|
|
1776
|
+
* called — i.e. the message provoked a terminal, unrecoverable failure — and `"held"`
|
|
1777
1777
|
* when the provider refused the turn and it ended in the rate-limit hold: not delivered, not
|
|
1778
|
-
* failed.
|
|
1778
|
+
* failed. Round 3 (MED-3): the caller uses this to decide whether the message may be
|
|
1779
1779
|
* marked delivered/read.
|
|
1780
1780
|
*
|
|
1781
|
-
*
|
|
1781
|
+
* round 5 review (INFO) — this method is non-reentrant BY CONSTRUCTION, and the
|
|
1782
1782
|
* `suppressProviderIdle`/`turnInFlight`/`turnClosed`/`turnAbandoned` instance fields it owns
|
|
1783
1783
|
* assume that: every `adapter.deliver`/`deliverInitialPrompt` call site in runner-core runs inside
|
|
1784
1784
|
* `providerInput.run`, which serializes them, so a second `send()` call never starts before the
|
|
@@ -1787,44 +1787,44 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1787
1787
|
*/
|
|
1788
1788
|
private async send(process: ManagedProcess, text: string, inputObserver?: ProviderInputObserver): Promise<OpenCodeSendOutcome> {
|
|
1789
1789
|
await this.apiBudgets.get(process)?.prepare();
|
|
1790
|
-
//
|
|
1790
|
+
// Phase B — retain the live process for the soft-steer wake hook (see its doc).
|
|
1791
1791
|
this.softSteerProcess = process;
|
|
1792
1792
|
let attempt = 0;
|
|
1793
1793
|
let rerolled = false;
|
|
1794
1794
|
let lastFailureDetail: string | undefined;
|
|
1795
1795
|
let lastFailureErrorType: string | undefined;
|
|
1796
|
-
//
|
|
1796
|
+
// round 4 review (F1/F2) — round 3 only raised this flag on a failed, retryable attempt,
|
|
1797
1797
|
// so it stayed false while ATTEMPT 1's own POST was in flight. On a fast (e.g. loopback) server
|
|
1798
1798
|
// the FAILED-attempt idle SSE frame routinely beats that attempt's own POST response — the
|
|
1799
1799
|
// issue's own captured incident shape, a 429 on the very first attempt — so an unsuppressed
|
|
1800
|
-
// attempt-1 idle still closed the turn early and, via runner-core's
|
|
1800
|
+
// attempt-1 idle still closed the turn early and, via runner-core's seam, marked the
|
|
1801
1801
|
// linked Task done while the retry loop was still running. Raised for the WHOLE call (below,
|
|
1802
1802
|
// past the readiness guard — round 5 LOW-2) so there is no "attempt 1 is unguarded" window.
|
|
1803
1803
|
// Every return path must therefore close the turn itself — `closeSuppressedTurn` does that,
|
|
1804
1804
|
// idempotently per call (`turnClosed`) — see the `finally`.
|
|
1805
|
-
//
|
|
1805
|
+
// close a still-open event-driven terminal turn BEFORE the reset below: a chat
|
|
1806
1806
|
// delivery taking over the boundary must end the terminal turn first (its own SSE idle, if
|
|
1807
1807
|
// it even arrived, would be swallowed under this call's suppression), and closeEventTurn
|
|
1808
1808
|
// sets `turnClosed` — resetting it after the close keeps THIS call's deterministic close
|
|
1809
1809
|
// live. Order: terminal idle clears the terminal turn id, then this call's busy mints its own.
|
|
1810
1810
|
if (this.eventTurnId) this.closeEventTurn();
|
|
1811
|
-
//
|
|
1811
|
+
// round 1 (review D) — a chat delivery also consumes any armed terminal prompt: a
|
|
1812
1812
|
// stale arm surviving this call would wrongly admit this call's own buffered busy echo
|
|
1813
1813
|
// after the close. (This call's POST user echo is recorded in seenUserMessageIds live, so
|
|
1814
1814
|
// its late duplicate can never re-arm — see the message.updated door.)
|
|
1815
1815
|
this.pendingTerminalPrompt = false;
|
|
1816
|
-
//
|
|
1816
|
+
// round 2 (review D) — a buffered terminal prompt flushed under the session
|
|
1817
1817
|
// pseudo-turn BEFORE the delivery takes over: the operator typed first, and the buffer
|
|
1818
1818
|
// must never leak across the headless turn boundary. Fallback id is the session
|
|
1819
1819
|
// pseudo-turn — never this call's (or any prior headless) sendTurnId.
|
|
1820
1820
|
this.flushBufferedPrompt(stringValue(process.meta?.sessionId) || undefined);
|
|
1821
1821
|
this.turnClosed = false;
|
|
1822
|
-
//
|
|
1822
|
+
// reset at the top of every call: a stray leftover event from a truly-abandoned
|
|
1823
1823
|
// PREVIOUS turn must not be allowed to affect THIS new call's turn boundary (see the field
|
|
1824
1824
|
// comment).
|
|
1825
1825
|
this.turnAbandoned = false;
|
|
1826
1826
|
this.serverRetryWall = undefined;
|
|
1827
|
-
//
|
|
1827
|
+
// review (HIGH-1) — one fresh turn id per send (stable across this call's retries/
|
|
1828
1828
|
// rerolls, which are ONE logical turn), so every send is distinct and the busy edge below hands
|
|
1829
1829
|
// runner-core this exact id as its currentTurnId.
|
|
1830
1830
|
this.sendTurnId = `opencode-turn-${crypto.randomUUID()}`;
|
|
@@ -1834,27 +1834,27 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1834
1834
|
const sessionId = stringValue(process.meta?.sessionId);
|
|
1835
1835
|
const model = stringValue(process.meta?.model);
|
|
1836
1836
|
if (!client || !sessionId || !model) throw new Error("OpenCode session is not ready with a hub-resolved model");
|
|
1837
|
-
//
|
|
1837
|
+
// round 5 review (LOW-2) — raised only once we're actually about to run an attempt
|
|
1838
1838
|
// (past the guard above), so a "session not ready" throw never emits a close for a turn
|
|
1839
1839
|
// that never started (never emitted its own busy edge below).
|
|
1840
1840
|
this.turnInFlight = true;
|
|
1841
1841
|
this.suppressProviderIdle = true;
|
|
1842
|
-
//
|
|
1842
|
+
// round 1 (review D) — a headless turn now exists on this process: from here on,
|
|
1843
1843
|
// terminal-origin busies are admitted only through the armed fresh-prompt gate.
|
|
1844
1844
|
this.headlessTurnSeen = true;
|
|
1845
1845
|
const providerState = attempt > 0 || rerolled
|
|
1846
1846
|
? openCodeUpstreamRetryState({ attempt, rerolled, message: lastFailureDetail ?? "retrying", errorType: lastFailureErrorType })
|
|
1847
1847
|
: undefined;
|
|
1848
1848
|
this.statusCb({ status: "busy", reason: "provider-turn", ...(this.sendTurnId ? { id: this.sendTurnId } : {}), ...(providerState ? { providerState } : {}) });
|
|
1849
|
-
//
|
|
1849
|
+
// this endpoint is synchronous: it returns the COMPLETED assistant message. That
|
|
1850
1850
|
// body is ground truth about whether the model produced anything, so it feeds the same
|
|
1851
1851
|
// evidence funnel the cross-provider ledger reads, rather than being discarded as it was
|
|
1852
1852
|
// during the incident. Only a positively-parsed non-empty completion counts; an
|
|
1853
1853
|
// unrecognised body says nothing.
|
|
1854
|
-
//
|
|
1854
|
+
// compose once outside this retry loop, but the durable first-turn markers
|
|
1855
1855
|
// are consumed by PROOF OF ACCEPTANCE, not by this await's return: `sendOpenCodeRelayTurn`
|
|
1856
1856
|
// arms the SSE acceptance door before dispatch and reconciles against persisted session
|
|
1857
|
-
// messages if the await itself fails, so an accepted-but-interrupted turn (e.g. the
|
|
1857
|
+
// messages if the await itself fails, so an accepted-but-interrupted turn (e.g. the
|
|
1858
1858
|
// whole-turn abort) still retains the consumed flags instead of re-prepending the briefing
|
|
1859
1859
|
// on every queued message. A readiness guard or rejected transport request has not reached
|
|
1860
1860
|
// the provider, so RunnerCore's retry must still include the briefing. Retryable resolved
|
|
@@ -1869,7 +1869,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1869
1869
|
throw error;
|
|
1870
1870
|
}
|
|
1871
1871
|
if (this.holdOnServerRetryWall()) return "held";
|
|
1872
|
-
//
|
|
1872
|
+
// every completion (success, a produced-output error, or a still-retrying error)
|
|
1873
1873
|
// carries this turn's fresh token usage; report it to Relay's normal context telemetry path
|
|
1874
1874
|
// the same way Codex's `codexTokenUsageContext` does, rather than only on the happy path.
|
|
1875
1875
|
this.reportContextUsage(process, model, completion.tokens);
|
|
@@ -1879,14 +1879,14 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1879
1879
|
if (completion.error && completion.error.name !== "MessageAbortedError") {
|
|
1880
1880
|
const error = completion.error;
|
|
1881
1881
|
const detail = `${error.name}${error.statusCode !== undefined ? ` ${error.statusCode}` : ""}: ${error.message}`;
|
|
1882
|
-
//
|
|
1882
|
+
// round 2 (F1/F2) — `completion.error` used to be checked independently of any
|
|
1883
1883
|
// output the turn already produced. A turn that ran tool steps (a commit, say) and THEN
|
|
1884
1884
|
// hit a retryable error was resent into a brand-new session with no memory of that work
|
|
1885
1885
|
// — re-executing it. And a non-retryable error on a turn that DID produce a reply (e.g.
|
|
1886
1886
|
// MessageOutputLengthError after a long, otherwise-successful answer) took a healthy
|
|
1887
1887
|
// agent offline. Retrying or failing must only ever happen when the turn produced
|
|
1888
1888
|
// NOTHING; with output present, report the error and let the turn end normally.
|
|
1889
|
-
//
|
|
1889
|
+
// round 3 (HIGH-1) — `completionProducedOutput` excludes bare step-start/finish
|
|
1890
1890
|
// bookkeeping parts. The issue's own captured incident (one bare step-start part, zero
|
|
1891
1891
|
// tokens, a 429) used to satisfy the old parts>0 check and take THIS branch — no retry,
|
|
1892
1892
|
// no error status, reported as a clean success. It must fall through to the retry path.
|
|
@@ -1897,10 +1897,10 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1897
1897
|
this.emitTurnFinalResponse(process, completion);
|
|
1898
1898
|
this.closeSuppressedTurn();
|
|
1899
1899
|
this.turnActivityCb();
|
|
1900
|
-
this.rerolledThisHoldEpisode = false; //
|
|
1900
|
+
this.rerolledThisHoldEpisode = false; // the provider took a turn: the hold episode is over
|
|
1901
1901
|
return "ok";
|
|
1902
1902
|
}
|
|
1903
|
-
//
|
|
1903
|
+
// an explicit account usage/quota wall: in-session retries and a re-roll cannot
|
|
1904
1904
|
// clear it (the re-roll would only drop the conversation). Hold now on the same session;
|
|
1905
1905
|
// the relay resumes us. Checked BEFORE the non-retryable exit: a provider may send a
|
|
1906
1906
|
// quota wall as a non-retryable 403 ("Quota exceeded; limit will reset tomorrow").
|
|
@@ -1908,7 +1908,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1908
1908
|
this.holdTurnOnProviderLimit(error, detail);
|
|
1909
1909
|
return "held";
|
|
1910
1910
|
}
|
|
1911
|
-
//
|
|
1911
|
+
// throttle wording is retryable whatever the provider flagged (zai sends a
|
|
1912
1912
|
// non-retryable 403 "Rate limit exceeded"): retry, then hold like any throttle. A real
|
|
1913
1913
|
// auth/model refusal still fails here.
|
|
1914
1914
|
if (!error.isRetryable && !isProviderThrottle(error)) {
|
|
@@ -1920,10 +1920,10 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1920
1920
|
const delayMs = openCodeErrorRetryDelayMs(attempt);
|
|
1921
1921
|
lastFailureDetail = detail;
|
|
1922
1922
|
lastFailureErrorType = error.errorType;
|
|
1923
|
-
//
|
|
1923
|
+
// round 3 (MED-5) — `diagnostic: true`: this narrates the ADAPTER's own retry
|
|
1924
1924
|
// mechanics, not anything the model produced. Left uncredited it would let
|
|
1925
1925
|
// emptyTurns.noteOutput() (runner-core's onSessionEvent wiring) read a retry storm
|
|
1926
|
-
// that ultimately fails as forward progress, cancelling
|
|
1926
|
+
// that ultimately fails as forward progress, cancelling the input-armed escalation
|
|
1927
1927
|
// that is the only thing left to wake the spawn parent once failTurn takes the agent
|
|
1928
1928
|
// offline below.
|
|
1929
1929
|
this.sessionEventCb({ type: "narration", origin: "provider", diagnostic: true, body: `OpenCode turn failed (${detail}); retrying in ${Math.round(delayMs / 1000)}s (attempt ${attempt}/${OPENCODE_ERROR_RETRY_MAX_ATTEMPTS})…` });
|
|
@@ -1932,27 +1932,27 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1932
1932
|
await this.sleepImpl(delayMs);
|
|
1933
1933
|
continue;
|
|
1934
1934
|
}
|
|
1935
|
-
//
|
|
1935
|
+
// a provider overload (5xx) is provider-wide, not a broken session: a re-roll
|
|
1936
1936
|
// would only drop the history before the same wall. Hold on the SAME session.
|
|
1937
1937
|
if (isProviderOverload(error)) {
|
|
1938
1938
|
this.holdTurnOnProviderLimit(error, `${detail} (after ${OPENCODE_ERROR_RETRY_MAX_ATTEMPTS} in-session retries)`);
|
|
1939
1939
|
return "held";
|
|
1940
1940
|
}
|
|
1941
|
-
// Retry exhausted for the CURRENT session. Bimodal-per-session evidence (
|
|
1941
|
+
// Retry exhausted for the CURRENT session. Bimodal-per-session evidence (issue
|
|
1942
1942
|
// comment) says an ambiguous 429 can be a per-session failure — re-roll to a fresh one
|
|
1943
|
-
// once.
|
|
1943
|
+
// once. At most ONCE PER HOLD EPISODE (until a turn goes through again), so a
|
|
1944
1944
|
// long wall re-rolls once and then holds: history loss stays bounded to one attempt.
|
|
1945
1945
|
if (!rerolled && !this.rerolledThisHoldEpisode) {
|
|
1946
1946
|
this.rerolledThisHoldEpisode = true;
|
|
1947
|
-
//
|
|
1947
|
+
// round 2 (F5) — say plainly that this drops the conversation: re-rolling
|
|
1948
1948
|
// mutates process.meta.sessionId to a brand-new, history-less session, which for a
|
|
1949
1949
|
// long-lived agent throws away everything said so far.
|
|
1950
1950
|
this.sessionEventCb({ type: "narration", origin: "provider", diagnostic: true, body: `OpenCode turn failed ${OPENCODE_ERROR_RETRY_MAX_ATTEMPTS} times in a row in this session (${detail}); re-rolling to a new OpenCode session (this drops the current conversation history) and retrying…` });
|
|
1951
1951
|
let newSessionId: string;
|
|
1952
1952
|
try {
|
|
1953
|
-
newSessionId = await client.createSession(stringValue(process.meta?.sessionTitle) ?? "
|
|
1953
|
+
newSessionId = await client.createSession(stringValue(process.meta?.sessionTitle) ?? "humwick");
|
|
1954
1954
|
} catch (createError) {
|
|
1955
|
-
//
|
|
1955
|
+
// the original session is still intact; a failed re-roll during a
|
|
1956
1956
|
// provider outage is no reason to end the agent. Hold like the exhausted path.
|
|
1957
1957
|
this.holdTurnOnProviderLimit(error, `${detail} (session re-roll failed: ${errMessage(createError)})`);
|
|
1958
1958
|
return "held";
|
|
@@ -1961,7 +1961,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1961
1961
|
// process.meta.sessionId, so mutating it in place re-routes the whole adapter to the
|
|
1962
1962
|
// new session — no separate "which session is active" bookkeeping needed.
|
|
1963
1963
|
if (process.meta) process.meta.sessionId = newSessionId;
|
|
1964
|
-
//
|
|
1964
|
+
// Phase B — soft-steer fencing on re-roll: same contract as the clear swap —
|
|
1965
1965
|
// accepted-but-unconsumed batches on the dead session reoffer to the normal queue
|
|
1966
1966
|
// (the settlement callback fires inside sessionReplaced).
|
|
1967
1967
|
const softSteerOrphans = await this.softSteer.sessionReplaced(newSessionId);
|
|
@@ -1975,7 +1975,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1975
1975
|
// suppressProviderIdle is already raised for the whole call — see entry.
|
|
1976
1976
|
continue;
|
|
1977
1977
|
}
|
|
1978
|
-
//
|
|
1978
|
+
// retries and the re-roll are exhausted on a RETRYABLE error: the provider is
|
|
1979
1979
|
// down or throttling, not the agent. This used to failTurn → terminal `error` → runner
|
|
1980
1980
|
// exit, killing every zai session ~10 min into a quota wall. Hold and let the relay's
|
|
1981
1981
|
// resume sweep back off and wake the agent, as it does for Claude.
|
|
@@ -1987,24 +1987,24 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
1987
1987
|
this.emitTurnFinalResponse(process, completion);
|
|
1988
1988
|
this.closeSuppressedTurn();
|
|
1989
1989
|
this.turnActivityCb();
|
|
1990
|
-
this.rerolledThisHoldEpisode = false; //
|
|
1990
|
+
this.rerolledThisHoldEpisode = false; // the provider took a turn: the hold episode is over
|
|
1991
1991
|
return "ok";
|
|
1992
1992
|
}
|
|
1993
1993
|
// Either a non-empty completion with no positively-parsable output (an unrecognised body —
|
|
1994
|
-
// says nothing either way), or a POSITIVELY parsed empty completion (
|
|
1994
|
+
// says nothing either way), or a POSITIVELY parsed empty completion (the normal
|
|
1995
1995
|
// empty-turn path, closed deterministically here rather than depending on a possibly late
|
|
1996
1996
|
// or lost SSE idle frame). Either way this POST returning is itself proof the turn is over
|
|
1997
1997
|
// — the endpoint is synchronous — so let the shared `finally` below perform the close
|
|
1998
|
-
// instead of duplicating it at two near-identical return sites.
|
|
1998
|
+
// instead of duplicating it at two near-identical return sites. Round 4 review (F3):
|
|
1999
1999
|
// that exact duplication was unadjudicated — deleting either explicit call left every
|
|
2000
2000
|
// opencode test green, since `finally` already produces the identical close with no
|
|
2001
2001
|
// observable ordering difference from here (nothing else runs between this `return` and
|
|
2002
2002
|
// the `finally`).
|
|
2003
|
-
this.rerolledThisHoldEpisode = false; //
|
|
2003
|
+
this.rerolledThisHoldEpisode = false; // the provider took a turn: the hold episode is over
|
|
2004
2004
|
return "ok";
|
|
2005
2005
|
}
|
|
2006
2006
|
} finally {
|
|
2007
|
-
//
|
|
2007
|
+
// round 4/5 review (F2) — the single deterministic close for this whole call. Some
|
|
2008
2008
|
// return paths above call `closeSuppressedTurn()` explicitly first (when something else in
|
|
2009
2009
|
// that branch — `turnActivityCb`, most notably — must run AFTER the close, so ordering
|
|
2010
2010
|
// matters); the rest fall straight through to here, since nothing runs between their
|
|
@@ -2012,7 +2012,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2012
2012
|
// (see F3 above). `failTurn`'s own unconditional close also marks `turnClosed`, so by the
|
|
2013
2013
|
// time ANY return path reaches here this is a no-op — EXCEPT when NONE of those ran: a throw
|
|
2014
2014
|
// from `client.sendMessage` (transport reset, socket drop) or the "session not ready" guard.
|
|
2015
|
-
//
|
|
2015
|
+
// round 6 review (MED-2r) — that guard throw must NOT fall through to
|
|
2016
2016
|
// `closeSuppressedTurn()`. The old comment here claimed `suppressProviderIdle` "was also
|
|
2017
2017
|
// never raised this call, so closeSuppressedTurn is a no-op there too" — true only on the
|
|
2018
2018
|
// FIRST call. `suppressProviderIdle` is STICKY (round 5, MED-1): on any later call, if the
|
|
@@ -2029,9 +2029,9 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2029
2029
|
// Closing here (when `turnStartedThisCall` is true — a genuine mid-attempt throw) trades a
|
|
2030
2030
|
// turn the server might still be running for a turn that reliably ends; the adapter has no
|
|
2031
2031
|
// other signal on a throw, and `failTurn` already makes that same trade unconditionally today.
|
|
2032
|
-
//
|
|
2032
|
+
// `turnClosed` still being false at this point means NEITHER a success branch nor
|
|
2033
2033
|
// `failTurn` ran before we got here — the only way in is an uncaught throw (e.g. `sendMessage`
|
|
2034
|
-
// aborting on the
|
|
2034
|
+
// aborting on the turn-timeout, or any other transport failure) whose actual server-side
|
|
2035
2035
|
// outcome is unknown. Mark the turn abandoned before force-closing it, so a genuine same-
|
|
2036
2036
|
// session busy/idle that arrives later can still self-heal the status instead of being
|
|
2037
2037
|
// swallowed forever by the otherwise-permanent `suppressProviderIdle` (see its field doc).
|
|
@@ -2043,20 +2043,20 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2043
2043
|
}
|
|
2044
2044
|
|
|
2045
2045
|
/**
|
|
2046
|
-
*
|
|
2046
|
+
* the runner's `latestProcessContext` (runner-core.ts) reads `process.meta.context`
|
|
2047
2047
|
* directly (the same fallback path Codex's `meta.context` sets from `thread/tokenUsage/updated`),
|
|
2048
2048
|
* feeding Relay's normal telemetry (dashboard/API/timeline) and the context-pressure warning /
|
|
2049
2049
|
* self-compaction advisory. A no-op whenever the turn carried no parsable token count or the
|
|
2050
2050
|
* model's window is unknown — silence here, not a fabricated 0/0 reading.
|
|
2051
2051
|
*/
|
|
2052
2052
|
private reportContextUsage(process: ManagedProcess, model: string | undefined, tokensUsed: number | undefined): void {
|
|
2053
|
-
//
|
|
2053
|
+
// precedence payload-record > legacy window > manifest. The record's qualified
|
|
2054
2054
|
// name is ALSO threaded through: meta.model is re-pinned to the server-computed qualified name
|
|
2055
2055
|
// when the record is present, so later reportContextUsage calls (which read meta.model) keep
|
|
2056
2056
|
// resolving against the payload instead of re-deriving from a possibly-ambiguous alias.
|
|
2057
2057
|
const config = process.meta?.config as RunnerSpawnConfig | undefined;
|
|
2058
2058
|
const record = openCodeModelRecord(config?.modelRecord, this.provider);
|
|
2059
|
-
//
|
|
2059
|
+
// the configured window belongs to the LAUNCH model, not a terminal-switched one.
|
|
2060
2060
|
const configWindow = openCodeOnLaunchModel(process.meta, model) ? record?.contextWindowTokens ?? config?.contextWindowTokens : undefined;
|
|
2061
2061
|
const windowTokens = typeof configWindow === "number" && Number.isFinite(configWindow) && configWindow > 0
|
|
2062
2062
|
? configWindow
|
|
@@ -2066,7 +2066,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2066
2066
|
}
|
|
2067
2067
|
|
|
2068
2068
|
/**
|
|
2069
|
-
*
|
|
2069
|
+
* round 2 (F6) — once a retry (or the post-reroll retry) actually produces output, the
|
|
2070
2070
|
* operator-visible "retrying…"/blocked hold must not linger on screen until whatever SSE idle
|
|
2071
2071
|
* happens to arrive next — which `suppressProviderIdle` above can delay well past this turn.
|
|
2072
2072
|
* Clear it explicitly and immediately; a no-op when no retry hold was ever shown.
|
|
@@ -2081,7 +2081,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2081
2081
|
}
|
|
2082
2082
|
|
|
2083
2083
|
/**
|
|
2084
|
-
*
|
|
2084
|
+
* round 4/5 review (F1/F2/MED-1) — `suppressProviderIdle` is raised for the WHOLE `send`
|
|
2085
2085
|
* call, from just past the readiness guard (see the loop entry), so every path that ends the
|
|
2086
2086
|
* turn — success, either failure, or the `finally`'s throw fallback — must close it
|
|
2087
2087
|
* deterministically itself; the synchronous POST returning (or the throw itself) is ground truth
|
|
@@ -2095,20 +2095,20 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2095
2095
|
private closeSuppressedTurn(hold?: ProviderState): void {
|
|
2096
2096
|
if (this.turnClosed) return;
|
|
2097
2097
|
this.turnClosed = true;
|
|
2098
|
-
//
|
|
2098
|
+
// round 1 review (F2/F3) — turn close: flush the debounced token-usage report now
|
|
2099
2099
|
// rather than wait out its timer. Not awaited — a live turn boundary, not teardown; flush()
|
|
2100
2100
|
// already routes failures to reporter.reportFailure internally, so the `.catch(() => {})`
|
|
2101
|
-
// here only satisfies the unguarded-async ratchet
|
|
2101
|
+
// here only satisfies the unguarded-async ratchet — it never actually fires.
|
|
2102
2102
|
void this.tokenUsagePoster.flushAll().catch(() => {});
|
|
2103
2103
|
this.statusCb({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], ...(hold ? { providerState: hold } : {}) });
|
|
2104
|
-
//
|
|
2104
|
+
// Phase B — idle edge with unconsumed soft batches: arm the one bounded wake. Never
|
|
2105
2105
|
// into a provider-limit hold: the wake would only hit the same wall; the relay's resume turn
|
|
2106
2106
|
// ends in a normal idle that arms it then.
|
|
2107
2107
|
if (!hold) this.maybeArmSoftSteerWake();
|
|
2108
2108
|
}
|
|
2109
2109
|
|
|
2110
2110
|
/**
|
|
2111
|
-
*
|
|
2111
|
+
* end the turn in the shared rate-limit hold instead of a terminal failure: the turn
|
|
2112
2112
|
* closes (idle + provider-turn) carrying the `blocked/rate_limit` providerState, which
|
|
2113
2113
|
* runner-core keeps as its rateLimitHold until the relay's resume turn goes busy. The session
|
|
2114
2114
|
* (and its history, including the refused user message) stays alive.
|
|
@@ -2120,7 +2120,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2120
2120
|
}
|
|
2121
2121
|
|
|
2122
2122
|
/**
|
|
2123
|
-
*
|
|
2123
|
+
* OpenCode's own retry of an in-flight send: a transient one is shown as "retrying"; a wall
|
|
2124
2124
|
* (usage/free-tier limit, far-off `next`, or an endless throttle) aborts the session so the POST
|
|
2125
2125
|
* returns and send() ends the turn in the shared rate-limit hold instead of waiting silently.
|
|
2126
2126
|
*/
|
|
@@ -2146,11 +2146,11 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2146
2146
|
}
|
|
2147
2147
|
|
|
2148
2148
|
/**
|
|
2149
|
-
*
|
|
2149
|
+
* close the event-driven (terminal-origin) turn on its matching SSE idle: emit the
|
|
2150
2150
|
* turn-final response accumulated from the turn's finalized assistant text parts (the same
|
|
2151
2151
|
* narration→final-response shape send()'s POST path produces, and the same turnId so the
|
|
2152
2152
|
* dashboard's per-turn narration/response dedup applies), then the idle edge that closes
|
|
2153
|
-
* runner-core's turn. Completes the
|
|
2153
|
+
* runner-core's turn. Completes the abandonment self-heal (the server's own truth for
|
|
2154
2154
|
* the abandoned turn has now arrived). Deliberately does NOT re-raise the sticky idle
|
|
2155
2155
|
* suppression or stamp the echo window: an event turn's busy+idle arrive LIVE — they DRIVE
|
|
2156
2156
|
* this state machine; there is no send()-POST await whose buffered chunk could replay them —
|
|
@@ -2160,7 +2160,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2160
2160
|
private closeEventTurn(): void {
|
|
2161
2161
|
const turnId = this.eventTurnId;
|
|
2162
2162
|
const text = this.eventTurnText.join("\n\n").trim();
|
|
2163
|
-
//
|
|
2163
|
+
// reconcile the turn's reasoning parts against what the live door already emitted,
|
|
2164
2164
|
// per part id: a part whose latest snapshot EQUALS its last-emitted body needs no row (it is
|
|
2165
2165
|
// already traced); a part that GREW after its last live emit — or was never live-emitted
|
|
2166
2166
|
// (frames racing the close, a part observed only at close time) — gets one row under its OWN
|
|
@@ -2180,8 +2180,8 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2180
2180
|
this.eventTurnReasoning = new Map();
|
|
2181
2181
|
// Stray idles with no open event turn stay swallowed via the existing `turnClosed` guard.
|
|
2182
2182
|
this.turnClosed = true;
|
|
2183
|
-
//
|
|
2184
|
-
// turn. Round 1 (review D) — re-latch the sticky suppression it had bypassed (the
|
|
2183
|
+
// self-heal completion: this idle close was the server's own truth for the abandoned
|
|
2184
|
+
// turn. Round 1 (review D) — re-latch the sticky suppression it had bypassed (the
|
|
2185
2185
|
// escape hatch exists only while the abandonment is unresolved), so post-heal strays stay
|
|
2186
2186
|
// swallowed under the same permanent suppression as every other send()-closed turn.
|
|
2187
2187
|
if (this.turnAbandoned) {
|
|
@@ -2192,17 +2192,17 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2192
2192
|
for (const step of unseen) this.emitReasoningFinal(openCodeReasoningSessionEvent(step, turnId, false));
|
|
2193
2193
|
for (const event of openCodeTurnFinalEvents({ text }, turnId)) this.sessionEventCb(event);
|
|
2194
2194
|
}
|
|
2195
|
-
//
|
|
2195
|
+
// round 1 review (F2/F3) — same flush as closeSuppressedTurn's turn close; the
|
|
2196
2196
|
// `.catch(() => {})` is the same ratchet-only no-op (flush() already self-catches).
|
|
2197
2197
|
void this.tokenUsagePoster.flushAll().catch(() => {});
|
|
2198
2198
|
this.statusCb({ status: "idle", reason: "provider-turn", clear: ["provider-turn"] });
|
|
2199
|
-
//
|
|
2199
|
+
// Phase B — a terminal-origin turn's close is an idle edge too: unconsumed soft
|
|
2200
2200
|
// batches get the same single bounded wake.
|
|
2201
2201
|
this.maybeArmSoftSteerWake();
|
|
2202
2202
|
}
|
|
2203
2203
|
|
|
2204
2204
|
/**
|
|
2205
|
-
*
|
|
2205
|
+
* round 2 (review D) — emit a buffered terminal prompt under `turnId` and clear the
|
|
2206
2206
|
* slot. `turnId` is the freshly minted terminal turn id (primary path — busy admission) or
|
|
2207
2207
|
* the session pseudo-turn (bounded no-busy fallbacks); it is NEVER a stale headless
|
|
2208
2208
|
* sendTurnId, which only exists while send() is in flight and is never passed here.
|
|
@@ -2215,7 +2215,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2215
2215
|
}
|
|
2216
2216
|
|
|
2217
2217
|
/**
|
|
2218
|
-
*
|
|
2218
|
+
* the turn id live session events should attach to: send's per-call id while a
|
|
2219
2219
|
* headless delivery is in flight, otherwise the event-driven terminal turn minted on the
|
|
2220
2220
|
* server's own busy edge. Deliberately NO fallback to a stale `sendTurnId` outside send() —
|
|
2221
2221
|
* that inheritance is exactly the contamination this replaces; callers fall back to the
|
|
@@ -2226,10 +2226,10 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2226
2226
|
}
|
|
2227
2227
|
|
|
2228
2228
|
private failTurn(detail: string, terminalFailureReason: string): void {
|
|
2229
|
-
//
|
|
2229
|
+
// round 3 (MED-5) — diagnostic:true; see the retry-loop narration above for why.
|
|
2230
2230
|
this.sessionEventCb({ type: "narration", origin: "provider", diagnostic: true, body: `OpenCode turn failed: ${detail}` });
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2231
|
+
// round 2 (F3) — an "error" status alone is never treated as a turn boundary by
|
|
2232
|
+
// seam in runner-core (only `idle && provider-turn` clears `currentTurnId` and notes
|
|
2233
2233
|
// the turn ended); without this, the next `busy` reuses the stale turn id and the empty-turn
|
|
2234
2234
|
// detector is blinded for the rest of the agent's life. Close the turn through that same seam
|
|
2235
2235
|
// first — exactly as the parsed-empty-completion path above already does — before marking the
|
|
@@ -2237,7 +2237,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2237
2237
|
// raised), so mark `turnClosed` here too — otherwise `send()`'s `finally` fallback would see it
|
|
2238
2238
|
// still unset and emit a second, out-of-order idle after the error status below.
|
|
2239
2239
|
this.turnClosed = true;
|
|
2240
|
-
//
|
|
2240
|
+
// round 1 review (F2/F3) — same flush as the other turn-close sites: a failed turn can
|
|
2241
2241
|
// still carry real (partial) usage worth persisting; the `.catch(() => {})` is the same
|
|
2242
2242
|
// ratchet-only no-op as the other two sites.
|
|
2243
2243
|
void this.tokenUsagePoster.flushAll().catch(() => {});
|
|
@@ -2246,7 +2246,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
|
|
|
2246
2246
|
status: "error",
|
|
2247
2247
|
reason: "provider-turn",
|
|
2248
2248
|
clear: ["provider-turn"],
|
|
2249
|
-
//
|
|
2249
|
+
// mirrors the Claude adapter's own "model-unavailable" stop-failure shape
|
|
2250
2250
|
// (providerState + an embedded timeline event) instead of leaving this error silent beyond
|
|
2251
2251
|
// the mirror log: a terminal failure used to reach runner-core with no timeline event and no
|
|
2252
2252
|
// classification, so an exit driven by this status surfaced nothing to the operator beyond
|