@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/runner-core.ts
CHANGED
|
@@ -37,7 +37,6 @@ import { resolveTestRunRootPid, TEST_RUN_DEDUP_SWEEP_MS, TestRunDedupSweep } fro
|
|
|
37
37
|
import { cachedGateRunCommandsForCwd } from "./gate-run-config";
|
|
38
38
|
import { AreaMemoryTracker } from "./area-memory";
|
|
39
39
|
import { ClaimTracker } from "./claim-tracker";
|
|
40
|
-
import { ProviderQuotaHarvest } from "./adapters/provider-quota-harvest";
|
|
41
40
|
import { agentShellLaunchEnv } from "./adapters/project-shell";
|
|
42
41
|
import { isNotificationOnlyInput } from "./inbox-carrier";
|
|
43
42
|
import { readRunnerContextProbeState } from "./context-probe-state";
|
|
@@ -50,7 +49,7 @@ import { retryParkPost } from "./park-post";
|
|
|
50
49
|
import { getManifest } from "@humwick/providers";
|
|
51
50
|
import { hostSourcesFor } from "./host-asset-plan";
|
|
52
51
|
import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
|
|
53
|
-
import {
|
|
52
|
+
import { HumwickMcpProxy, isolatedWorkspaceDescriptor } from "./relay-mcp-proxy";
|
|
54
53
|
import { runtimeMetadata } from "./version";
|
|
55
54
|
import { logger } from "./logger";
|
|
56
55
|
import { ensureSessionScratch, type SessionScratchLayout } from "./session-scratch";
|
|
@@ -107,21 +106,21 @@ type HeldSubmit = { messages: Map<number, Message>; resend?: () => Promise<unkno
|
|
|
107
106
|
const emptyHeldSubmit = (): HeldSubmit => ({ messages: new Map() });
|
|
108
107
|
type RunnerStatusFrame = { agentStatus: string; ready?: boolean; meta?: Record<string, unknown> };
|
|
109
108
|
|
|
110
|
-
//
|
|
109
|
+
// how often to reconcile pid-bearing busy work against actual process liveness.
|
|
111
110
|
const BUSY_LIVENESS_RECONCILE_MS = 5_000;
|
|
112
111
|
const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
|
|
113
112
|
const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
|
|
114
113
|
// Bound retries after a terminal provider failure.
|
|
115
114
|
const TERMINAL_FAILURE_RETRY_BASE_MS = 2_000;
|
|
116
115
|
const TERMINAL_FAILURE_RETRY_MAX_MS = 60_000;
|
|
117
|
-
//
|
|
116
|
+
// live-fire — recency bound for ids this runner has positively SETTLED (acked + marked
|
|
118
117
|
// read). A server list snapshot taken before the settle's markRead (the startup unread poll,
|
|
119
118
|
// any future server-listed lane) can land its stale copy in enqueueMessage AFTER the delivery —
|
|
120
119
|
// re-delivering an already-delivered message (immediately as a steer under the soft-steer
|
|
121
120
|
// default, or at turn-end before it). Insertion-ordered, oldest evicted; the race window is
|
|
122
121
|
// intra-process and short, so a recency cap is generous.
|
|
123
122
|
const SETTLED_MESSAGE_IDS_CAP = 1024;
|
|
124
|
-
//
|
|
123
|
+
// Phase B — bound on soft-steered ids held between persist and consumption. The adapter's
|
|
125
124
|
// ledger cap backpressures first (it stops accepting new positive persists), so this is the
|
|
126
125
|
// runner-side defense-in-depth ceiling, not the operating bound.
|
|
127
126
|
const AWAITING_MID_TURN_DELIVERY_CAP = 256;
|
|
@@ -138,9 +137,9 @@ const PROMPT_ECHO_DEDUP_MS = 30_000;
|
|
|
138
137
|
// `<task-notification>` is the harness wrapper for async monitor/task wake-ups
|
|
139
138
|
// (which embed a `[relay message #…]` deeper inside, defeating a plain prefix
|
|
140
139
|
// match) — it is emitted only by the harness, never typed by a human, so any
|
|
141
|
-
// echo starting with it is a system injection, not a user prompt
|
|
142
|
-
const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay", "<task-notification>"];
|
|
143
|
-
//
|
|
140
|
+
// echo starting with it is a system injection, not a user prompt.
|
|
141
|
+
const RELAY_INJECTION_MARKERS = ["[relay message #", "[humwick", "[agent-relay", "<task-notification>"];
|
|
142
|
+
// bounded settle-poll for provider pre-flush anchors. The adapter owns the
|
|
144
143
|
// source-specific read; the runner owns the shared timeout knobs.
|
|
145
144
|
const SETTLE_POLL_INTERVAL_MS = 250;
|
|
146
145
|
const SETTLE_POLL_TIMEOUT_MS = 4_000;
|
|
@@ -153,10 +152,10 @@ export class AgentRunner {
|
|
|
153
152
|
private get bus() { return this.context.bus; }
|
|
154
153
|
private readonly effortRuntime: Pick<RunnerOptions, "instanceId" | "adapter" | "effort">;
|
|
155
154
|
private readonly claims = new ClaimTracker();
|
|
156
|
-
// Phase 2
|
|
155
|
+
// Phase 2: the Stop hook reads reply obligations from this local snapshot, never
|
|
157
156
|
// from the server — so a slow server can no longer wedge a turn (the crux fix).
|
|
158
157
|
private readonly obligationCache: ReplyObligationCache;
|
|
159
|
-
// Phase 2
|
|
158
|
+
// Phase 2: Runner→server append-log events (session turns, reasoning, prompts,
|
|
160
159
|
// insights, hook-fatal) go through this durable, disk-backed, timestamped queue instead of
|
|
161
160
|
// direct fire-and-forget HTTP — so nothing is lost across a server/Runner restart.
|
|
162
161
|
private readonly outbox: Outbox;
|
|
@@ -167,7 +166,7 @@ export class AgentRunner {
|
|
|
167
166
|
private readonly lifecycleMilestones: LifecycleMilestones;
|
|
168
167
|
private readonly promptCommands: PromptCommands;
|
|
169
168
|
private readonly pendingPermissionStore: PendingPermissionStore;
|
|
170
|
-
//
|
|
169
|
+
// F1 — the one gate in front of the provider's single input channel. Every payload the
|
|
171
170
|
// runner delivers (message batch, injected prompt, self-resume continuation, memory preamble)
|
|
172
171
|
// passes through it, so two payloads for one agent can never interleave at the wire. FIFO, with
|
|
173
172
|
// the place in line taken synchronously at dispatch, so the payload the relay created first is
|
|
@@ -175,9 +174,9 @@ export class AgentRunner {
|
|
|
175
174
|
private readonly providerInput = new ProviderInputQueue({
|
|
176
175
|
onStuck: (heldMs) => logger.warn("delivery", `provider input stream held for ${heldMs}ms; force-releasing so delivery cannot wedge`),
|
|
177
176
|
});
|
|
178
|
-
//
|
|
177
|
+
// converges the provider on Relay's desired effort between turns; a bus notice only speeds it up.
|
|
179
178
|
private readonly effort = new EffortReconciler({ http: () => this.http, agentId: () => this.agentId, runner: () => this.effortRuntime, process: () => this.process, publish: () => this.bus.flushHeartbeat(), hasProviderTurn: () => this.claims.activeWork().some((w) => w.kind === "provider-turn"), reserveInput: () => this.providerInput.reserve(), log: (message) => this.sessionLog(message) });
|
|
180
|
-
//
|
|
179
|
+
// "did that turn actually do anything", and what to do when the answer is no. The ledger,
|
|
181
180
|
// its timers and the nudge/escalate ladder all live in empty-turn.ts; what stays here is the
|
|
182
181
|
// wiring only AgentRunner can own — the adapter, the process handle and the input queue.
|
|
183
182
|
private readonly emptyTurns = new EmptyTurnWatch({
|
|
@@ -192,16 +191,15 @@ export class AgentRunner {
|
|
|
192
191
|
private deliveringEmptyTurnNudge = false;
|
|
193
192
|
private readonly insights: RunnerInsights;
|
|
194
193
|
private readonly busyReconciler: BusyReconciler;
|
|
195
|
-
private readonly providerQuotaHarvest: ProviderQuotaHarvest;
|
|
196
194
|
private readonly credentials: Credentials;
|
|
197
195
|
private control?: ControlServer;
|
|
198
196
|
private registeredAt?: number;
|
|
199
|
-
// Stage 2
|
|
200
|
-
// Runner owns reconnect/backoff + a durable buffer. Disabled via
|
|
197
|
+
// Stage 2: the local MCP endpoint the agent connects to, fronting the relay so the
|
|
198
|
+
// Runner owns reconnect/backoff + a durable buffer. Disabled via HUMWICK_MCP_PROXY=0
|
|
201
199
|
// (then the agent connects to the relay directly, Stage-1 behaviour). The proxy secret is the
|
|
202
200
|
// bearer the agent presents to the localhost proxy — it decouples the agent from the rotating
|
|
203
201
|
// relay token (the proxy injects the live token relay-side).
|
|
204
|
-
private proxy?:
|
|
202
|
+
private proxy?: HumwickMcpProxy;
|
|
205
203
|
private mcpProxyEndpoint?: string;
|
|
206
204
|
private readonly mcpProxyEnabled: boolean;
|
|
207
205
|
private readonly mcpProxySecret: string;
|
|
@@ -210,9 +208,9 @@ export class AgentRunner {
|
|
|
210
208
|
suspendHeartbeat: () => this.bus.suspendHeartbeat(),
|
|
211
209
|
stopStatusWatchers: () => this.options.adapter.stopStatusWatchers?.(),
|
|
212
210
|
});
|
|
213
|
-
//
|
|
211
|
+
// set when the provider terminally exits but the runner stays alive (native
|
|
214
212
|
// auto-compaction / manual-resume hold). Rides on the offline status frame so the server fires a
|
|
215
|
-
// terminal `agent.exited` event with a
|
|
213
|
+
// terminal `agent.exited` event with a diagnosis instead of the death going silent. Cleared
|
|
216
214
|
// on any genuine restart back to busy/idle.
|
|
217
215
|
private terminalProviderExit?: Record<string, unknown>;
|
|
218
216
|
private nativeSelfResume = new NativeSelfResumeTracker();
|
|
@@ -226,12 +224,12 @@ export class AgentRunner {
|
|
|
226
224
|
private readonly testRunDedupSweep: TestRunDedupSweep;
|
|
227
225
|
private processStartedAt = 0;
|
|
228
226
|
private providerSessionId = crypto.randomUUID();
|
|
229
|
-
// Last transcript path seen this session — used by end-of-session Insights
|
|
227
|
+
// Last transcript path seen this session — used by end-of-session Insights
|
|
230
228
|
// when the SessionEnd hook payload omits it.
|
|
231
229
|
private lastTranscriptPath?: string;
|
|
232
230
|
private readonly unexpectedExitTimes: number[] = [];
|
|
233
231
|
private readonly pendingMessages = new Map<number, Message>();
|
|
234
|
-
// Hold inbox input through clear+inject and the bounded late-commit grace
|
|
232
|
+
// Hold inbox input through clear+inject and the bounded late-commit grace.
|
|
235
233
|
private readonly continuationFence = new ClearInjectInboxFence(() => { if (this.pendingMessages.size > 0) this.scheduleDrain(); });
|
|
236
234
|
private readonly lateClearRecovery = new LateClearRecovery((commandId) => this.finishLateClearGrace(commandId));
|
|
237
235
|
private readonly deliverySettlements = new DeliverySettlements();
|
|
@@ -265,51 +263,52 @@ export class AgentRunner {
|
|
|
265
263
|
// Display-only startup modal evidence. It never touches work claims or turn state.
|
|
266
264
|
private startupPaneDialog?: ProviderState;
|
|
267
265
|
private deliverySubmitHold?: ProviderState;
|
|
268
|
-
/**
|
|
266
|
+
/** what the hold withheld: the unconfirmed item for the operator's resend, and later prompts parked in order behind it. */
|
|
269
267
|
private heldSubmit: HeldSubmit = emptyHeldSubmit();
|
|
270
268
|
private readonly submitDeliveryAttempts = new Map<number, number>();
|
|
271
269
|
private submitRecoveryInFlight = false;
|
|
272
270
|
private submitRecoveryTurnStarted = false;
|
|
273
271
|
private submitRecoveryRequested = false;
|
|
274
|
-
//
|
|
272
|
+
// a held submit that did land often leaves no later event to re-run the recovery probe.
|
|
275
273
|
private submitRecoveryTimer?: ReturnType<typeof setInterval>;
|
|
276
274
|
private submitRecoveryPollMs = 10_000;
|
|
277
275
|
private submitRecoveryPollWindowMs = 5 * 60_000;
|
|
278
|
-
//
|
|
276
|
+
// the operator's resend of the held prompt runs ahead of every parked inbox row.
|
|
279
277
|
private submitResendInFlight = false;
|
|
280
|
-
//
|
|
278
|
+
// set INSIDE the FIFO slot of an unconfirmed submit, so the next queued prompt sees the fence
|
|
281
279
|
// before the failing caller has published the hold; cleared with the hold.
|
|
282
280
|
private submitFenceRaised = false;
|
|
283
|
-
//
|
|
281
|
+
// park posts (and their retries) run in the input order, their place taken at the parking decision.
|
|
284
282
|
private readonly parkPosts = new ProviderInputQueue({ onStuck: (heldMs) => logger.warn("delivery", `parked prompt post held ${heldMs}ms; releasing the next`) });
|
|
285
283
|
private terminalFailure?: { reason: string; message: string; providerState?: ProviderState };
|
|
286
|
-
//
|
|
284
|
+
// a usage/rate-limit hold. The turn truly ended (idle), but the agent is
|
|
287
285
|
// held until the limit resets, so this can't ride an active-work claim like the
|
|
288
286
|
// permission-blocked state does. publishStatus surfaces it as the agent's
|
|
289
287
|
// providerState; it clears when a new turn starts (the relay's resume message
|
|
290
288
|
// wakes one) so the blocked badge lifts on its own.
|
|
291
289
|
private rateLimitHold?: ProviderState;
|
|
292
290
|
private pendingRateLimitHold?: ProviderState;
|
|
293
|
-
//
|
|
291
|
+
// like rateLimitHold — a deny-then-hold "questions" (AskUserQuestion) prompt's own
|
|
294
292
|
// turn genuinely ends (the provider reacts to the denial and stops normally) before the human
|
|
295
293
|
// answers, so the pendingApproval can't ride the provider-turn active-work claim (it would
|
|
296
294
|
// vanish the instant that claim clears on the real idle). Persists across idles and new turns
|
|
297
|
-
//
|
|
295
|
+
// cleared once the answer is injected (or the prompt is dismissed/times out).
|
|
298
296
|
private pendingQuestionHold?: ProviderState;
|
|
299
|
-
//
|
|
297
|
+
// the turn-end capture saw accepted input queued behind this turn: its idle opens the next turn.
|
|
300
298
|
private queuedTurnFollows = false;
|
|
301
|
-
// Settle-poll cadence for the pre-flush anchor
|
|
299
|
+
// Settle-poll cadence for the pre-flush anchor — instance fields (not just the
|
|
302
300
|
// module constants) so tests can shrink them without waiting out the real bound.
|
|
303
301
|
private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
|
|
304
302
|
private settlePollTimeoutMs = SETTLE_POLL_TIMEOUT_MS;
|
|
305
303
|
private scratch?: SessionScratchLayout;
|
|
306
|
-
|
|
304
|
+
private readonly registrationMeta: Record<string, unknown>;
|
|
305
|
+
// tracks how many times each spawner obligation has been re-injected so a
|
|
307
306
|
// non-responsive provider can't spin in a re-injection loop.
|
|
308
307
|
private readonly pendingObligationInjections = new Map<number, number>();
|
|
309
|
-
//
|
|
308
|
+
// live-fire — ids positively settled (acked + marked read) by THIS process; enqueueMessage
|
|
310
309
|
// drops stale re-enqueues of them. Bounded recency (see SETTLED_MESSAGE_IDS_CAP).
|
|
311
310
|
private readonly settledMessageIds = new Set<number>();
|
|
312
|
-
//
|
|
311
|
+
// defense-in-depth backstop under the server-side decideDelivery rule: content hashes of
|
|
313
312
|
// the system-owned context payloads (memory/identity/charter/startup-context/self-resume
|
|
314
313
|
// continuation) already delivered to the CURRENT provider context generation. A byte-identical
|
|
315
314
|
// re-inject within one generation is skipped (a future double-emitting path must not paste the
|
|
@@ -320,23 +319,23 @@ export class AgentRunner {
|
|
|
320
319
|
// repeat. This is a safety net, not the primary fix: the server's history classification of the
|
|
321
320
|
// recovery artifact is what removes the cause.
|
|
322
321
|
private readonly injectedContextHashes = new Set<string>();
|
|
323
|
-
//
|
|
322
|
+
// which path areas this context generation entered; reset with injectedContextHashes.
|
|
324
323
|
private readonly areaMemory = new AreaMemoryTracker({ agentId: () => this.agentId, cwd: () => this.options.cwd, providerSessionId: () => this.providerSessionId, http: () => this.http, log: (message) => this.sessionLog(message) });
|
|
325
|
-
//
|
|
324
|
+
// Phase B — soft-steered batches accepted by the provider but NOT yet consumed: the
|
|
326
325
|
// persisted-but-unconsumed window. The runner keeps the offered Message so the adapter's ledger
|
|
327
326
|
// can settle by id alone ({@link settleSoftSteerConsumption} marks read;
|
|
328
327
|
// {@link reofferSoftSteerIds} requeues the ORIGINAL message into the normal lane). Bounded —
|
|
329
328
|
// past the cap settle falls back to the un-acked requeue path (the adapter's own ledger cap
|
|
330
329
|
// backpressures before this, so the fallback is defense-in-depth).
|
|
331
330
|
private readonly awaitingMidTurnDelivery = new Map<number, Message>();
|
|
332
|
-
//
|
|
331
|
+
// the hold/resume state machine: hold episodes + the ONE parked queue of refused input
|
|
333
332
|
// (unread, claim released, never re-drained into the wall). See provider-hold.ts.
|
|
334
333
|
private readonly hold = new ProviderHoldEpisodes();
|
|
335
334
|
private readonly refusedPrompts: RefusedPromptStore;
|
|
336
|
-
//
|
|
337
|
-
// must still do that the refused injectContext did not (the
|
|
335
|
+
// refused context injections parked as prompts (by heldPromptKey): what their ACCEPTANCE
|
|
336
|
+
// must still do that the refused injectContext did not (the hash, the clear fence release).
|
|
338
337
|
private readonly parkedContextInjections = new Map<string, { contentHash: string; afterClearCommandId?: string }>();
|
|
339
|
-
//
|
|
338
|
+
// round 3 review (MED-3r): same ladder, for a message an adapter reports via
|
|
340
339
|
// DeliverResult.terminalFailureIds — without this, a re-queued message whose provider just
|
|
341
340
|
// exhausted a terminal-failure retry budget (e.g. OpenCode against a rate-limited upstream) was
|
|
342
341
|
// re-attempted on the very next tick, forever, with no backoff. Cleared once it is acknowledged.
|
|
@@ -401,12 +400,12 @@ export class AgentRunner {
|
|
|
401
400
|
updateCommand: (id, input) => this.bus.updateCommand(id, input), fetchCommand: (id) => this.http.request("GET", `/api/commands/${encodeURIComponent(id)}`), destructiveInputUnsettled: (id) => this.contextResets.unsettled(id), checkAcknowledgement: (ack, status) => this.nativeSelfResume.checkAcknowledgement(ack, status),
|
|
402
401
|
});
|
|
403
402
|
this.attachmentCacheSweep = new AttachmentCacheSweep(this.agentId, (message) => this.logRunnerDiagnostic(message));
|
|
404
|
-
//
|
|
403
|
+
// "latest wins" test-run dedup, scoped to THIS session's command-owning provider
|
|
405
404
|
// process tree. Interactive Codex exposes a visible TUI pid separately from its App Server;
|
|
406
405
|
// commands descend from the latter, so resolveTestRunRootPid prefers the runner-owned child.
|
|
407
406
|
// No pid ⇒ no scope ⇒ no signalling: a sweep that cannot prove ownership must never act.
|
|
408
407
|
const sessionProviderPid = () => resolveTestRunRootPid(this.process, (managedProcess) => this.options.adapter.resolveBusyPid?.(managedProcess));
|
|
409
|
-
//
|
|
408
|
+
// ancestry alone made this sweep a no-op for `humwick_run` gate runs (a disjoint tree
|
|
410
409
|
// under the orchestrator). It now also owns anything env-tagged with this agent's id, classifies
|
|
411
410
|
// against the repo's CONFIGURED gate commands, and reads live leases during each sweep. The
|
|
412
411
|
// config read is cached for the session — it is a repo file, so re-reading it every 15s for the
|
|
@@ -427,8 +426,8 @@ export class AgentRunner {
|
|
|
427
426
|
this.bus.setToken(token);
|
|
428
427
|
// The proxy reads the token live via getToken(), so forwarding already uses the new one.
|
|
429
428
|
// A re-mint can change scope (e.g. a profile change), so refresh the relay tool list and
|
|
430
|
-
// emit tools/list_changed if the visible set changed
|
|
431
|
-
//
|
|
429
|
+
// emit tools/list_changed if the visible set changed.
|
|
430
|
+
// a re-register follows a relay restart (a deploy): re-list so changed tools reach the agent.
|
|
432
431
|
void this.proxy?.refreshTools().catch(() => {});
|
|
433
432
|
},
|
|
434
433
|
resumeLivenessAndPublishRotation: (status, jti) => {
|
|
@@ -436,7 +435,7 @@ export class AgentRunner {
|
|
|
436
435
|
// An earlier auth failure may have stopped the liveness loop; restart it so the
|
|
437
436
|
// agent reports live again on the fresh token. startLiveness clears any
|
|
438
437
|
// existing timer first, so this is safe on the normal (proactive) renew path too.
|
|
439
|
-
this.
|
|
438
|
+
this.liveness.start();
|
|
440
439
|
this.pendingTimelineEvent = { status, id: jti, timestamp: Date.now() };
|
|
441
440
|
this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
|
|
442
441
|
this.publishStatus();
|
|
@@ -459,22 +458,13 @@ export class AgentRunner {
|
|
|
459
458
|
report: (record) => this.tokenUsageCoverage.report(record, (r) => this.http.reportTokenUsage(this.agentId, r)),
|
|
460
459
|
capture: (key, read, pending) => this.tokenUsageCoverage.capture(key, read, pending),
|
|
461
460
|
debug: (message) => this.sessionDebug(message),
|
|
462
|
-
// Capture/POST failures must be visible at INFO alongside empty passes
|
|
461
|
+
// Capture/POST failures must be visible at INFO alongside empty passes.
|
|
463
462
|
reportFailure: (error) => this.sessionLog(`token-usage capture failed: ${errMessage(error)}`),
|
|
464
463
|
});
|
|
465
|
-
this.providerQuotaHarvest = new ProviderQuotaHarvest({
|
|
466
|
-
agentId: this.agentId,
|
|
467
|
-
provider: options.provider,
|
|
468
|
-
// #2514 — the per-provider config env carries a dedicated-home provider's (claude-b's) own
|
|
469
|
-
// credential, which the quota harvest needs to key its reports under the B subscription.
|
|
470
|
-
providerConfigEnv: () => this.options.providerConfig.env,
|
|
471
|
-
});
|
|
472
464
|
this.liveness = new LivenessExchange({
|
|
473
465
|
http: this.http, bus: () => this.bus, agentId: this.agentId, instanceId: options.instanceId, logIntervalMs: HTTP_LIVENESS_LOG_INTERVAL_MS,
|
|
474
466
|
isStopped: () => this.lifecycle.stopped, isTerminalStatusLatched: () => this.lifecycle.terminalStatusLatched,
|
|
475
467
|
currentStatus: () => this.claims.currentStatus(),
|
|
476
|
-
sampleQuota: () => this.providerQuotaHarvest.sample(),
|
|
477
|
-
acceptQuota: (report, result) => this.providerQuotaHarvest.accept(report, result),
|
|
478
468
|
enqueueMessage: (message) => this.enqueueMessage(message), triggerEffort: () => this.effort.trigger(),
|
|
479
469
|
recoverRuntimeTokenAfterAuthFailure: (source) => this.credentials.recoverRuntimeTokenAfterAuthFailure(source),
|
|
480
470
|
logRunnerDiagnostic: (message) => this.logRunnerDiagnostic(message),
|
|
@@ -590,7 +580,7 @@ export class AgentRunner {
|
|
|
590
580
|
]),
|
|
591
581
|
],
|
|
592
582
|
tags: [...new Set([options.provider, ...csvTags(tagsFromEnv()), ...options.tags, ...options.providerConfig.defaultTags, ...(options.headless ? ["headless"] : [])])],
|
|
593
|
-
meta: runnerIdentityMeta(this.options, { runtime, agentId: this.agentId, tmuxSession: providerTerminalSession(this.process), tmuxSocket: providerTerminalSocket(this.process), auth: { profileId: this.credentials.profileId, jti: this.credentials.jti, expiresAt: this.credentials.expiresAt } }, "registration"),
|
|
583
|
+
meta: this.registrationMeta = runnerIdentityMeta(this.options, { runtime, agentId: this.agentId, tmuxSession: providerTerminalSession(this.process), tmuxSocket: providerTerminalSocket(this.process), auth: { profileId: this.credentials.profileId, jti: this.credentials.jti, expiresAt: this.credentials.expiresAt } }, "registration"),
|
|
594
584
|
heartbeatMeta: () => this.heartbeatRuntimeMeta(),
|
|
595
585
|
heartbeatIntervalMs: 30_000,
|
|
596
586
|
initialStatus: "idle",
|
|
@@ -602,25 +592,25 @@ export class AgentRunner {
|
|
|
602
592
|
}
|
|
603
593
|
|
|
604
594
|
private registerProviderSessionRoutes(): void {
|
|
605
|
-
//
|
|
595
|
+
// neither publisher has a try/catch of its own, and both bottom out in
|
|
606
596
|
// sessionOutbox.enqueue: a synchronous bun:sqlite INSERT that throws outright once the outbox
|
|
607
597
|
// is closed (teardown ordering in stop(): the adapter keeps emitting after close) or the
|
|
608
598
|
// database errors. This is the highest-frequency fire-and-forget site in the runner — every
|
|
609
599
|
// provider session event passes through it — so leaving it unguarded is the likeliest way a
|
|
610
|
-
// live agent silently stops existing (Bun exits(1) on an unhandled rejection
|
|
611
|
-
//
|
|
600
|
+
// live agent silently stops existing (Bun exits(1) on an unhandled rejection).
|
|
601
|
+
// turn-output evidence is counted HERE, at the registration seam, not inside the
|
|
612
602
|
// publish functions: `publishProviderSessionEvents` delegates to the single-event path only for
|
|
613
603
|
// `prompt`/`response` and handles every other type itself, and Claude's reasoning tail emits
|
|
614
604
|
// ALL of its tool/reasoning steps through that batch. Counting downstream missed them, so a
|
|
615
605
|
// Claude turn that ran a dozen tools and produced no extractable closing text counted zero.
|
|
616
606
|
this.options.adapter.onSessionEvent?.((event) => { this.emptyTurns.noteOutput(event); this.areaMemory.noteToolPaths([event]); if (event.type === "prompt") this.nativeSelfResume.notePrompt(event.body, event.occurredAt); void this.sessionMirror.publishProviderSessionEvent(event).catch((error) => this.reportBackgroundRejection("provider session event publish", "session.publish_failed", "Provider session event not mirrored", error)); });
|
|
617
607
|
this.options.adapter.onSessionEvents?.((events) => { this.areaMemory.noteToolPaths(events); for (const event of events) { this.emptyTurns.noteOutput(event); if (event.type === "prompt") this.nativeSelfResume.notePrompt(event.body, event.occurredAt); } void this.sessionMirror.publishProviderSessionEvents(events).catch((error) => this.reportBackgroundRejection("provider session event batch publish", "session.publish_failed", "Provider session events not mirrored", error)); });
|
|
618
|
-
//
|
|
608
|
+
// output a provider can observe but cannot mirror (an OpenCode tool part, whose wire
|
|
619
609
|
// shape we deliberately do not guess at). Evidence only; nothing is published.
|
|
620
610
|
this.options.adapter.onTurnActivity?.(() => this.emptyTurns.noteOutput());
|
|
621
611
|
}
|
|
622
612
|
|
|
623
|
-
// The adapter's status seam, split out of run
|
|
613
|
+
// The adapter's status seam, split out of run so the guarded restart call below is
|
|
624
614
|
// reachable without booting a whole runner — the crash it guards against is exactly the one a
|
|
625
615
|
// test has to be able to drive.
|
|
626
616
|
/** Pane evidence can change only the displayed startup dialog and timeline, never work claims. */
|
|
@@ -640,7 +630,7 @@ export class AgentRunner {
|
|
|
640
630
|
}
|
|
641
631
|
|
|
642
632
|
private handleAdapterStatusChange(status: ProviderStatusUpdate): void {
|
|
643
|
-
//
|
|
633
|
+
// stop publishes the runner's terminal edge directly. Once it has started, an
|
|
644
634
|
// adapter callback can only be stale: an input already inside ProviderInputQueue may finish
|
|
645
635
|
// after stopStatusWatchers() and arm a fresh provider watcher, while an older watcher may
|
|
646
636
|
// still be returning from an async probe. Neither is allowed to reopen terminal status.
|
|
@@ -649,18 +639,18 @@ export class AgentRunner {
|
|
|
649
639
|
const semanticStatus = typeof status === "string" ? status : status.status;
|
|
650
640
|
if ((semanticStatus === "busy" || semanticStatus === "offline" || semanticStatus === "error") && this.startupPaneDialog) this.startupPaneDialog = undefined;
|
|
651
641
|
if (this.shouldRestartUnexpectedProviderExit(semanticStatus)) {
|
|
652
|
-
//
|
|
642
|
+
// restartUnexpectedProviderExit is an outer try/finally with NO catch wrapped
|
|
653
643
|
// around awaited work that can still throw (recoverNativeSelfResumeExit, the undetermined-
|
|
654
644
|
// exit confirmation read, the timeline/status publishes). A reject here used to kill the
|
|
655
645
|
// runner outright: the crash path for the exact situation this method exists to recover
|
|
656
|
-
// from, on a runner nothing respawns
|
|
646
|
+
// from, on a runner nothing respawns.
|
|
657
647
|
void this.restartUnexpectedProviderExit(semanticStatus).catch((error) => this.reportBackgroundRejection("unexpected provider exit restart", "provider.restart_failed", "Provider restart after unexpected exit failed", error));
|
|
658
648
|
return;
|
|
659
649
|
}
|
|
660
650
|
this.setProviderStatus(status);
|
|
661
651
|
if (runnerShouldResolveProviderExit(semanticStatus, this.lifecycle.exitCommandInProgress)) {
|
|
662
652
|
const exitCode = semanticStatus === "offline" ? 0 : 1;
|
|
663
|
-
//
|
|
653
|
+
// a provider-reported terminal failure (model retired/404, auth error, exhausted
|
|
664
654
|
// retries, ...) used to resolve straight to `onProviderExit` with nothing surfaced beyond
|
|
665
655
|
// the mirror log: the orchestrator's own exit diagnostics tail the process's stdout/stderr
|
|
666
656
|
// capture, which held only the provider's OWN startup output, not this. Log it here —
|
|
@@ -677,12 +667,12 @@ export class AgentRunner {
|
|
|
677
667
|
this.control = startControlServer({
|
|
678
668
|
onStatus: (status) => { if (!this.options.adapter.reportsContextResets) this.contextResets.observeStatus(status); this.setProviderStatus(status); },
|
|
679
669
|
onTerminalAttachSpec: () => this.terminalAttachSpec(),
|
|
680
|
-
//
|
|
670
|
+
// the current non-secret attachment generation, resolved from the live process
|
|
681
671
|
// the same way terminalAttachSpec reads it. Only wired when the adapter declares one;
|
|
682
672
|
// absent ⇒ the endpoint reports no generation and the orchestrator skips the check.
|
|
683
673
|
onTerminalGeneration: () => Promise.resolve(this.process ? this.options.adapter.terminalAttachGeneration?.(this.process) : undefined),
|
|
684
674
|
// Hot-path-safe: answered instantly from the local snapshot, never a server
|
|
685
|
-
// round-trip. The snapshot is kept warm by the background refresh below
|
|
675
|
+
// round-trip. The snapshot is kept warm by the background refresh below.
|
|
686
676
|
onReplyObligations: () => this.obligationCache.freshForNag(),
|
|
687
677
|
onSubagentUsage: this.options.adapter.captureSubagentTokenUsage?.bind(this.options.adapter),
|
|
688
678
|
onSessionTurn: (input) => this.turnReport.publishSessionTurn(input), onPermissionResolved: (input) => this.sessionMirror.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
|
|
@@ -720,6 +710,7 @@ export class AgentRunner {
|
|
|
720
710
|
if (isEffortNotice(eventType, data)) this.effort.trigger();
|
|
721
711
|
});
|
|
722
712
|
this.bus.on("error", (code, message) => this.handleBusError(String(code), String(message)));
|
|
713
|
+
this.ensureScratch();
|
|
723
714
|
await registerWithinDeadline(this.bus, registrationTimeoutMsFromEnv(), this.options.relayUrl, (reason) => logger.fatal("register", reason));
|
|
724
715
|
await this.bus.subscribe(["command.*"]);
|
|
725
716
|
this.registeredAt = Date.now();
|
|
@@ -728,9 +719,8 @@ export class AgentRunner {
|
|
|
728
719
|
this.obligationCache.start();
|
|
729
720
|
this.outbox.start();
|
|
730
721
|
this.sessionMirror.start();
|
|
731
|
-
this.ensureScratch();
|
|
732
722
|
void sweepRunnerStaleScratch({ listAgents: () => this.http.listAgents(), agentId: this.agentId, cwd: this.options.cwd, fallbackBaseDir: orchestratorBaseDirFromEnv(), log: (message) => this.logRunnerDiagnostic(message) });
|
|
733
|
-
this.
|
|
723
|
+
this.attachmentCacheSweep.start();
|
|
734
724
|
await attemptProviderStartup(async () => {
|
|
735
725
|
this.adoptProcess(await this.spawnProvider());
|
|
736
726
|
this.registration.writeRunnerInfoFile();
|
|
@@ -740,7 +730,7 @@ export class AgentRunner {
|
|
|
740
730
|
}, (status) => this.setProviderStatus(status));
|
|
741
731
|
await this.bootstrapUnreadMessages();
|
|
742
732
|
this.taskClaims.start();
|
|
743
|
-
this.
|
|
733
|
+
this.liveness.start();
|
|
744
734
|
this.testRunDedupSweep.start();
|
|
745
735
|
this.credentials.scheduleRuntimeTokenRenewal();
|
|
746
736
|
this.scheduleDrain();
|
|
@@ -758,15 +748,15 @@ export class AgentRunner {
|
|
|
758
748
|
await this.lifecycleMilestones.onStop("closed");
|
|
759
749
|
}
|
|
760
750
|
|
|
761
|
-
// Start the local MCP proxy the agent connects to
|
|
751
|
+
// Start the local MCP proxy the agent connects to. Forwards tool calls to the
|
|
762
752
|
// relay with the runner's LIVE token, buffers bufferable writes durably during a relay outage,
|
|
763
753
|
// and narrows the tool list to this agent's workspace context. Best-effort: if it can't bind,
|
|
764
754
|
// we fall back to a direct relay MCP connection (the agent env still works, no resilience).
|
|
765
|
-
//
|
|
755
|
+
// the on-demand relay set to hide from a lean worker's DIRECT relay client (so only the
|
|
766
756
|
// eager hot-path set boots), or undefined for any agent that keeps its full relay surface. The
|
|
767
757
|
// gate MIRRORS the callmux relay-downstream gate (launch-assembly) EXACTLY — worker profile,
|
|
768
758
|
// runner-managed deferral, sharedMcp on (the metaOnly callmux that fronts these exists), relay mcp
|
|
769
|
-
// on, no web tool off
|
|
759
|
+
// on, no web tool off — so the direct client is narrowed only when callmux fronts the set.
|
|
770
760
|
// ("proxy active" is implicit: only a constructed proxy consults this, and the relay downstream is
|
|
771
761
|
// added only when the proxy is active — the two are on/off together, never a half state.)
|
|
772
762
|
private startMcpProxy(): void {
|
|
@@ -777,7 +767,7 @@ export class AgentRunner {
|
|
|
777
767
|
() => ({ agentId: this.agentId, provider: this.options.provider, model: this.options.model ?? this.latestProbeMetrics()?.model ?? null, effort: this.effortRuntime.effort ?? this.latestProbeMetrics()?.effort ?? null }),
|
|
778
768
|
call => this.http.request("POST", "/api/web-tool-calls", call), process.env, runnerWebSearch(this.http)),
|
|
779
769
|
redactToolResponse: this.options.adapter.mcpToolResponseRedactor?.(this.options.cwd),
|
|
780
|
-
relayMcpEndpoint: relayMcpEndpoint(this.options.relayUrl
|
|
770
|
+
relayMcpEndpoint: relayMcpEndpoint(this.options.relayUrl),
|
|
781
771
|
getToken: () => this.credentials.current(),
|
|
782
772
|
authSecret: this.mcpProxySecret,
|
|
783
773
|
enqueueBuffered: call => this.outbox.enqueue({ kind: "mcp-tool-call", payload: { tool: call.tool, arguments: call.arguments }, idempotencyKey: call.idempotencyKey }),
|
|
@@ -791,7 +781,7 @@ export class AgentRunner {
|
|
|
791
781
|
private handleBusRegistered(): void {
|
|
792
782
|
if (this.process) this.publishStatus();
|
|
793
783
|
void this.nativeSelfResume.reconcile((id) => this.http.request("GET", `/api/commands/${encodeURIComponent(id)}`)).catch((error) => this.sessionLog(`compact settlement reconcile: ${errMessage(error)}`));
|
|
794
|
-
//
|
|
784
|
+
// a re-register follows a relay restart (a deploy): re-list so changed tools reach the agent.
|
|
795
785
|
void this.proxy?.refreshTools().catch(() => {}); this.effort.trigger();
|
|
796
786
|
}
|
|
797
787
|
|
|
@@ -800,7 +790,7 @@ export class AgentRunner {
|
|
|
800
790
|
return mode === "isolated";
|
|
801
791
|
}
|
|
802
792
|
|
|
803
|
-
//
|
|
793
|
+
// publish the new incarnation's capabilities (effortSwitch) before its first turn: the
|
|
804
794
|
// heartbeat spawnProvider resumes still describes the previous process, and the next is ~30s away.
|
|
805
795
|
private adoptProcess(process: ManagedProcess): void {
|
|
806
796
|
this.process = process;
|
|
@@ -815,42 +805,42 @@ export class AgentRunner {
|
|
|
815
805
|
this.turnReport.clearPreDestroyRun();
|
|
816
806
|
this.lifecycle.reopenProviderStatus();
|
|
817
807
|
this.lastTranscriptPath = undefined;
|
|
818
|
-
//
|
|
808
|
+
// a new provider process is a new context generation: its (empty) context may
|
|
819
809
|
// legitimately receive the same charter/memory bytes again, so the dedupe table resets.
|
|
820
810
|
this.resetContextGeneration();
|
|
821
811
|
const providerEnv = selectProviderConfigEnv(this.options.providerConfig.env, hostSourcesFor(this.options).settings);
|
|
812
|
+
// Runner ownership belongs to this process; provider tools must never overwrite it.
|
|
822
813
|
const env = writeChildEnv({}, {
|
|
823
814
|
...resolveEnvLayers([process.env, providerEnv]),
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
AGENT_RELAY_ID: this.agentId,
|
|
815
|
+
HUMWICK_RUNNER_PORT: String(this.control!.port),
|
|
816
|
+
HUMWICK_RUNNER_ID: this.options.runnerId,
|
|
817
|
+
HUMWICK_PROVIDER_SESSION_ID: this.providerSessionId,
|
|
818
|
+
HUMWICK_ID: this.agentId,
|
|
829
819
|
// The repo-name project id the runner uses for insight observations. Exposed so
|
|
830
820
|
// agent-side signals (e.g. /introspect) tag the SAME project and aggregate together,
|
|
831
821
|
// instead of the agent's worktree cwd splitting one repo into many "projects".
|
|
832
|
-
|
|
833
|
-
...(this.scratch ? {
|
|
822
|
+
HUMWICK_PROJECT: this.insightProject(),
|
|
823
|
+
...(this.scratch ? { HUMWICK_SCRATCH_DIR: this.scratch.tmpDir } : {}),
|
|
834
824
|
...await agentShellLaunchEnv(this.agentId, providerEnv),
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
...(this.credentials.current() ? {
|
|
825
|
+
HUMWICK_URL: this.options.relayUrl,
|
|
826
|
+
HUMWICK_APPROVAL: this.options.approvalMode,
|
|
827
|
+
...(this.credentials.current() ? { HUMWICK_TOKEN: this.credentials.current()! } : {}),
|
|
838
828
|
// Dedicated, un-clobberable credential for the injected MCP endpoint. A rig's
|
|
839
|
-
// settings.json `env.
|
|
840
|
-
// MCP-parse time → server-actor auth, no identity
|
|
841
|
-
// ${
|
|
829
|
+
// settings.json `env.HUMWICK_TOKEN` would override the scoped token above at
|
|
830
|
+
// MCP-parse time → server-actor auth, no identity. The MCP config references
|
|
831
|
+
// ${HUMWICK_SESSION_TOKEN}, which rigs never set. See runner/src/relay-mcp.ts.
|
|
842
832
|
//
|
|
843
|
-
// Stage 2
|
|
833
|
+
// Stage 2: when the proxy is active the agent connects to the LOCAL proxy, so this
|
|
844
834
|
// holds the per-session PROXY SECRET (not the relay token). The proxy injects the live
|
|
845
835
|
// relay token itself — the agent never holds it, and token rotation is invisible. With the
|
|
846
836
|
// proxy disabled this stays the scoped relay token (Stage-1 direct connection).
|
|
847
837
|
...(this.proxy
|
|
848
838
|
? { [RELAY_MCP_TOKEN_ENV]: this.mcpProxySecret }
|
|
849
839
|
: (this.credentials.current() ? { [RELAY_MCP_TOKEN_ENV]: this.credentials.current()! } : {})),
|
|
850
|
-
...(this.credentials.jti ? {
|
|
851
|
-
...(this.credentials.profileId ? {
|
|
852
|
-
...(this.credentials.expiresAt ? {
|
|
853
|
-
});
|
|
840
|
+
...(this.credentials.jti ? { HUMWICK_TOKEN_JTI: this.credentials.jti } : {}),
|
|
841
|
+
...(this.credentials.profileId ? { HUMWICK_TOKEN_PROFILE: this.credentials.profileId } : {}),
|
|
842
|
+
...(this.credentials.expiresAt ? { HUMWICK_TOKEN_EXPIRES_AT: String(this.credentials.expiresAt) } : {}),
|
|
843
|
+
}, ["HUMWICK_RUNNER_INFO_FILE"]);
|
|
854
844
|
const config = buildRunnerSpawnConfig({ ...this.options, effort: this.effortRuntime.effort }, {
|
|
855
845
|
getRelayToken: () => this.credentials.current(),
|
|
856
846
|
agentId: this.agentId,
|
|
@@ -859,14 +849,14 @@ export class AgentRunner {
|
|
|
859
849
|
...(opts.resumeId ? { resumeId: opts.resumeId } : {}),
|
|
860
850
|
...(opts.suppressPrompt ? { suppressPrompt: true } : {}),
|
|
861
851
|
...(opts.systemPromptAppend ? { systemPromptAppend: opts.systemPromptAppend } : {}),
|
|
862
|
-
// Stage 2
|
|
852
|
+
// Stage 2: the MCP endpoint the agent's client should target — the runner-local
|
|
863
853
|
// proxy when active, undefined when disabled (adapters fall back to the direct relay URL).
|
|
864
854
|
...(this.mcpProxyEndpoint ? { relayMcpEndpoint: this.mcpProxyEndpoint } : {}),
|
|
865
|
-
//
|
|
855
|
+
// the proxy secret, exposed to the assembler ONLY when the proxy is active, so the
|
|
866
856
|
// Codex per-agent metaOnly callmux can reach the relay endpoint on-demand THROUGH the proxy
|
|
867
|
-
// (live-token-injected — never the relay token on disk
|
|
857
|
+
// (live-token-injected — never the relay token on disk).
|
|
868
858
|
...(this.mcpProxyEndpoint ? { mcpProxySecret: this.mcpProxySecret } : {}),
|
|
869
|
-
//
|
|
859
|
+
// the shared host-listener URL the `callmux bridge` connects to (env → default).
|
|
870
860
|
// Always resolved; the assembler only emits the bridge when `agentProfile.sharedMcp` is on.
|
|
871
861
|
sharedMcpUrl: this.proxy?.webToolsUrl ?? resolveSharedMcpUrl(),
|
|
872
862
|
monitor: {
|
|
@@ -878,7 +868,7 @@ export class AgentRunner {
|
|
|
878
868
|
// of the runner core. Assembly sees only the resulting snapshot.
|
|
879
869
|
const { hydratedConfig, managedProcess } = await (async () => {
|
|
880
870
|
try {
|
|
881
|
-
return await spawnWithServiceAccess(this.http, config, this.options.adapter, this.providerSessionId, Boolean(this.options.profile && this.options.spawnRequestId));
|
|
871
|
+
return await spawnWithServiceAccess(this.http, { ...config, onLaunchWarning: (event) => this.publishRunnerTimelineEvent(event) }, this.options.adapter, this.providerSessionId, Boolean(this.options.profile && this.options.spawnRequestId));
|
|
882
872
|
} finally {
|
|
883
873
|
// A failed replacement remains observable through the runner's status projection. Do not
|
|
884
874
|
// also strand the reusable bus with heartbeats, quota/context metadata, and its ack
|
|
@@ -902,22 +892,22 @@ export class AgentRunner {
|
|
|
902
892
|
|
|
903
893
|
private enqueueMessage(message: Message): void {
|
|
904
894
|
if (!this.matchesMessage(message)) return;
|
|
905
|
-
//
|
|
895
|
+
// live-fire — a message this runner is ALREADY delivering (in this snapshot's drain,
|
|
906
896
|
// so a stale server-list copy racing the offer) or has already SETTLED (acked + marked read)
|
|
907
897
|
// is DONE: neither may re-enter the queue and be re-delivered (as a soft-steer into the very
|
|
908
898
|
// turn that consumed it, or at turn-end). The in-flight check clears in the drain's finally,
|
|
909
899
|
// so every legitimate requeue path (unacked, claim failure) re-enqueues fine.
|
|
910
900
|
if (this.drainingMessageIds?.has(message.id) || this.heldSubmit.messages.has(message.id)) return;
|
|
911
901
|
if (this.settledMessageIds.has(message.id)) return;
|
|
912
|
-
//
|
|
902
|
+
// Phase B — an id handed to the soft-steer lane (persisted, awaiting consumption) is in
|
|
913
903
|
// the adapter's ledger custody: a stale snapshot re-enqueue would double-POST the same body
|
|
914
904
|
// into the live turn. Same class as the settled guard above; the reoffer seam is the only way
|
|
915
905
|
// an awaiting id returns to this queue.
|
|
916
906
|
if (this.awaitingMidTurnDelivery.has(message.id)) return;
|
|
917
|
-
//
|
|
907
|
+
// a provider-refused id is parked until the provider takes a turn again; a poll
|
|
918
908
|
// snapshot re-enqueueing it would re-POST it into the wall.
|
|
919
909
|
if (this.hold.isParked(message.id)) return;
|
|
920
|
-
//
|
|
910
|
+
// the relay's push of a refused prompt's own inbox row: it binds the pending park (the
|
|
921
911
|
// push can beat the self-post's response), or — its caller already failed — is settled.
|
|
922
912
|
const heldPromptKey = message.payload?.heldPromptKey;
|
|
923
913
|
if (typeof heldPromptKey === "string" && this.hold.isPending(heldPromptKey)) {
|
|
@@ -929,7 +919,7 @@ export class AgentRunner {
|
|
|
929
919
|
void this.settleSilently(message).catch(() => {}); // markRead failures are already swallowed inside
|
|
930
920
|
return;
|
|
931
921
|
}
|
|
932
|
-
//
|
|
922
|
+
// the wake:false / kind:session suppression is now decided ONCE server-side (decideDelivery)
|
|
933
923
|
// and the server no longer emits a turn frame for it, so no client-side `payload.wake` / `kind`
|
|
934
924
|
// policy branch survives here. What remains is a belt-and-braces structural check, shared with the
|
|
935
925
|
// SDK provider tail (frameSuppressedFromTurn) so the two clients cannot drift: obey a non-`turn`
|
|
@@ -966,7 +956,7 @@ export class AgentRunner {
|
|
|
966
956
|
this.publishStatus();
|
|
967
957
|
}
|
|
968
958
|
|
|
969
|
-
/**
|
|
959
|
+
/** the operator's escape hatch: clear the hold and the adapter fence, optionally resend what was held. */
|
|
970
960
|
private async releaseSubmitHold(resend: boolean): Promise<Record<string, unknown>> {
|
|
971
961
|
const held = this.heldSubmit;
|
|
972
962
|
const released = this.deliverySubmitHold !== undefined;
|
|
@@ -991,7 +981,7 @@ export class AgentRunner {
|
|
|
991
981
|
}
|
|
992
982
|
|
|
993
983
|
/**
|
|
994
|
-
*
|
|
984
|
+
* a dashboard prompt that meets the submit hold (at entry, or in its FIFO slot behind the
|
|
995
985
|
* submit that raised it) parks as a durable self-addressed inbox row: it drains in order once the
|
|
996
986
|
* hold clears and survives a runner restart like any unread message. Not durable: the command fails.
|
|
997
987
|
*/
|
|
@@ -1050,7 +1040,7 @@ export class AgentRunner {
|
|
|
1050
1040
|
});
|
|
1051
1041
|
}
|
|
1052
1042
|
|
|
1053
|
-
// ===================================================================== //
|
|
1043
|
+
// ===================================================================== // Phase A — the ONE provider-input offer/settle seam.
|
|
1054
1044
|
//
|
|
1055
1045
|
// Everything that puts content in front of the provider's single input channel OFFERS through
|
|
1056
1046
|
// {@link offerProviderInput} (arm the empty-turn watch BEFORE the delivery is awaited — see
|
|
@@ -1064,7 +1054,7 @@ export class AgentRunner {
|
|
|
1064
1054
|
/**
|
|
1065
1055
|
* Offer one payload to the provider: arm the empty-turn watch for work-bearing input, run
|
|
1066
1056
|
* delivery through the FIFO input queue, and disarm the arm if the offer throws. On success
|
|
1067
|
-
* the arm stays because the delivery obliges the agent to produce work
|
|
1057
|
+
* the arm stays because the delivery obliges the agent to produce work. A notice-only
|
|
1068
1058
|
* batch is delivered without an arm because it owes no answer.
|
|
1069
1059
|
*/
|
|
1070
1060
|
private async offerProviderInput<T>(run: (inputObserver?: ProviderInputObserver) => Promise<T>, offeredNothing?: (result: T) => boolean, armEmptyTurn = true): Promise<T> {
|
|
@@ -1080,7 +1070,7 @@ export class AgentRunner {
|
|
|
1080
1070
|
}
|
|
1081
1071
|
}
|
|
1082
1072
|
|
|
1083
|
-
//
|
|
1073
|
+
// the ONLY two places runner code hands input to the provider (runner-input-seam.guard.test.ts
|
|
1084
1074
|
// enforces it). A provider may REFUSE input under a quota wall or throttle, so `onHeld` is a required
|
|
1085
1075
|
// parameter: no caller can drop a refusal and report the input delivered. Both run in the caller's
|
|
1086
1076
|
// FIFO slot (`ticket`), so the refusal outcome is judged in the serialized input order.
|
|
@@ -1091,12 +1081,12 @@ export class AgentRunner {
|
|
|
1091
1081
|
return this.providerInput.run(async () => {
|
|
1092
1082
|
await this.lateClearRecovery.ready();
|
|
1093
1083
|
if (this.lifecycle.stopped) throw new Error("runner stopped before provider prompt offer");
|
|
1094
|
-
//
|
|
1084
|
+
// decided in the slot: a prompt queued before the preceding submit raised the hold never reaches the fence.
|
|
1095
1085
|
if (onSubmitHeld && (this.deliverySubmitHold || this.submitFenceRaised)) {
|
|
1096
1086
|
onSubmitHeld();
|
|
1097
1087
|
return "submit-held" as const;
|
|
1098
1088
|
}
|
|
1099
|
-
if (!this.currentTurnId) this.turnReport.armTurnOpener([]); //
|
|
1089
|
+
if (!this.currentTurnId) this.turnReport.armTurnOpener([]); // a prompt-opened turn answers no relay message
|
|
1100
1090
|
if (await this.noteSubmitFence(() => this.options.adapter.deliverInitialPrompt!(this.process!, prompt, options)) !== "held") return "accepted" as const;
|
|
1101
1091
|
onHeld();
|
|
1102
1092
|
return "held" as const;
|
|
@@ -1135,7 +1125,7 @@ export class AgentRunner {
|
|
|
1135
1125
|
offered = select();
|
|
1136
1126
|
this.hold.noteOffered(offered);
|
|
1137
1127
|
if (options.settlement && offered.length > 0) this.deliverySettlements.track(options.settlement);
|
|
1138
|
-
//
|
|
1128
|
+
// armed BEFORE the offer: a synchronous provider starts, or runs, the turn inside deliver.
|
|
1139
1129
|
const opening = this.turnReport.openTurnOpener(offered, !this.currentTurnId, options.ctx);
|
|
1140
1130
|
const delivered = offered.length > 0 ? await this.noteSubmitFence(() => this.options.adapter.deliver(this.process!, offered, { ...opening.ctx, recordSubmittedPrompt: (text, images) => this.recordInjectedPrompt(text, images) })).catch((error) => { opening.settle(false); throw error; }) : [];
|
|
1141
1131
|
const normalized = normalizeDeliverResult(delivered, offered);
|
|
@@ -1149,7 +1139,7 @@ export class AgentRunner {
|
|
|
1149
1139
|
}
|
|
1150
1140
|
|
|
1151
1141
|
/**
|
|
1152
|
-
* Settle an offered message batch
|
|
1142
|
+
* Settle an offered message batch: the ONE place that records delivery
|
|
1153
1143
|
* attempts, marks acked messages read, releases their claims, and requeues everything the
|
|
1154
1144
|
* adapter did not accept (partial acks are the soft-steer contract — the un-acked subset
|
|
1155
1145
|
* re-offers toward turn-end). Returns the backoff (ms) to pace the next drain by: 0 for an
|
|
@@ -1165,7 +1155,7 @@ export class AgentRunner {
|
|
|
1165
1155
|
}
|
|
1166
1156
|
const acknowledgedIds = new Set(normalized.acknowledgedIds);
|
|
1167
1157
|
const terminalFailureIds = new Set(normalized.terminalFailureIds);
|
|
1168
|
-
//
|
|
1158
|
+
// Phase B — soft-steer accepted-but-unconsumed ids: persistence is NOT delivery, so they
|
|
1169
1159
|
// are neither acked (no mark read / claim release / settled-id poison) nor un-acked (no requeue —
|
|
1170
1160
|
// the provider row exists and must never be re-POSTed). Their settlement completes through
|
|
1171
1161
|
// {@link settleSoftSteerConsumption} / {@link reofferSoftSteerIds} when the adapter's ledger
|
|
@@ -1205,7 +1195,7 @@ export class AgentRunner {
|
|
|
1205
1195
|
this.rememberSettled(message.id);
|
|
1206
1196
|
this.parkedContextAccepted(message);
|
|
1207
1197
|
}
|
|
1208
|
-
//
|
|
1198
|
+
// round 3 review (MED-3r) — an un-acknowledged id the adapter attributes to a
|
|
1209
1199
|
// TERMINAL provider failure (as opposed to an ordinary mid-turn/busy defer) paces its own
|
|
1210
1200
|
// re-drain with bounded exponential backoff.
|
|
1211
1201
|
// Without this, a message whose provider just exhausted its own retry budget against an
|
|
@@ -1252,7 +1242,7 @@ export class AgentRunner {
|
|
|
1252
1242
|
this.rememberSettled(message.id);
|
|
1253
1243
|
}
|
|
1254
1244
|
|
|
1255
|
-
/**
|
|
1245
|
+
/** resume wakes, decided by the hold state machine (provider-hold.ts planWakes). */
|
|
1256
1246
|
private async resolveResumeWakes(messages: Message[]): Promise<Message[]> {
|
|
1257
1247
|
const plan = this.hold.planWakes(messages);
|
|
1258
1248
|
for (const wake of plan.settle) await this.settleSilently(wake);
|
|
@@ -1260,7 +1250,7 @@ export class AgentRunner {
|
|
|
1260
1250
|
return plan.batch;
|
|
1261
1251
|
}
|
|
1262
1252
|
|
|
1263
|
-
/**
|
|
1253
|
+
/** park a refused prompt PENDING, synchronously (call inside the FIFO slot). Returns its
|
|
1264
1254
|
* heldPromptKey; {@link persistParkedPrompt} then makes it durable. */
|
|
1265
1255
|
private parkRefusedPrompt(prompt: string, payload: Record<string, unknown> = {}): string {
|
|
1266
1256
|
const heldPromptKey = crypto.randomUUID();
|
|
@@ -1270,7 +1260,7 @@ export class AgentRunner {
|
|
|
1270
1260
|
}
|
|
1271
1261
|
|
|
1272
1262
|
/**
|
|
1273
|
-
*
|
|
1263
|
+
* post a pending parked prompt to this agent's own inbox so a restart re-reads it like any
|
|
1274
1264
|
* unread message. The relay idempotency key IS the heldPromptKey, so retries and an ambiguous
|
|
1275
1265
|
* (committed, response lost) post converge on ONE row. False: it could not be made durable — the
|
|
1276
1266
|
* park is dropped and the caller must report failure (never success).
|
|
@@ -1297,7 +1287,7 @@ export class AgentRunner {
|
|
|
1297
1287
|
if (messages.length > 0) this.scheduleDrain();
|
|
1298
1288
|
}
|
|
1299
1289
|
|
|
1300
|
-
/**
|
|
1290
|
+
/** a parked context injection was accepted: finish what its refused injectContext skipped. */
|
|
1301
1291
|
private parkedContextAccepted(message: Message): void {
|
|
1302
1292
|
const key = message.payload?.heldPromptKey;
|
|
1303
1293
|
const parked = typeof key === "string" ? this.parkedContextInjections.get(key) : undefined;
|
|
@@ -1307,7 +1297,7 @@ export class AgentRunner {
|
|
|
1307
1297
|
if (parked.afterClearCommandId) this.releaseSelfResumeClearFence(parked.afterClearCommandId);
|
|
1308
1298
|
}
|
|
1309
1299
|
|
|
1310
|
-
//
|
|
1300
|
+
// Phase B — stash a soft-steered Message for id-keyed settlement later. False ⇒ over the
|
|
1311
1301
|
// cap (caller falls back to the un-acked requeue path).
|
|
1312
1302
|
private trackAwaitingMidTurnDelivery(message: Message): boolean {
|
|
1313
1303
|
if (this.awaitingMidTurnDelivery.size >= AWAITING_MID_TURN_DELIVERY_CAP) return false;
|
|
@@ -1316,7 +1306,7 @@ export class AgentRunner {
|
|
|
1316
1306
|
}
|
|
1317
1307
|
|
|
1318
1308
|
/**
|
|
1319
|
-
*
|
|
1309
|
+
* Phase B — the runner-owned CONSUMED settlement for soft-steered ids, handed to the
|
|
1320
1310
|
* adapter through ProviderDeliverContext.settleMidTurnConsumed. Mirrors the acked tail of
|
|
1321
1311
|
* {@link settleMessageDelivery} exactly (mark read → release the claim → poison the intake
|
|
1322
1312
|
* guard) plus one belt: a pendingMessages entry is dropped, so a requeue racing the consumption
|
|
@@ -1334,7 +1324,7 @@ export class AgentRunner {
|
|
|
1334
1324
|
}
|
|
1335
1325
|
|
|
1336
1326
|
/**
|
|
1337
|
-
*
|
|
1327
|
+
* Phase B — the runner-owned REOFFER for soft-steered ids whose persisted row can no
|
|
1338
1328
|
* longer be consumed (session clear/compact/reroll swap, shutdown, reconcile-absent). Same
|
|
1339
1329
|
* destination as settle's un-acked path (normal queue, turn-end delivery), driven by the
|
|
1340
1330
|
* adapter's ledger. Ids no longer tracked (never stashed, or already settled/reoffered) are
|
|
@@ -1420,20 +1410,20 @@ export class AgentRunner {
|
|
|
1420
1410
|
if (deliverable.length === 0) return;
|
|
1421
1411
|
const deliverableIds = new Set(deliverable.map((message) => message.id));
|
|
1422
1412
|
prepared = prepared.filter((message) => deliverableIds.has(message.id));
|
|
1423
|
-
//
|
|
1413
|
+
// F1 — behind the same gate as injected payloads: a message batch and a boundary
|
|
1424
1414
|
// inject arriving together must not paste into each other.
|
|
1425
1415
|
// Preserve arrival-time evidence across the input queue; adapters decide whether delivery actually deferred.
|
|
1426
1416
|
const deliverCtx: ProviderDeliverContext = {
|
|
1427
1417
|
isRecipientMidTurn: () => this.claims.reasons().includes("provider-turn"),
|
|
1428
|
-
//
|
|
1418
|
+
// Phase B — the deferred-settlement handoff: the adapter's soft-steer ledger settles
|
|
1429
1419
|
// its persisted-but-unconsumed batches through these runner-owned seams (never its own
|
|
1430
1420
|
// settlement implementation).
|
|
1431
1421
|
settleMidTurnConsumed: (relayIds) => this.deliverySettlements.track(this.settleSoftSteerConsumption(relayIds)),
|
|
1432
1422
|
reofferMidTurnIds: (relayIds) => this.reofferSoftSteerIds(relayIds),
|
|
1433
1423
|
};
|
|
1434
1424
|
// OFFER — arm the empty-turn watch only for work-bearing input, serialize through the FIFO
|
|
1435
|
-
// input queue, disarm on rejection. (
|
|
1436
|
-
//
|
|
1425
|
+
// input queue, disarm on rejection. (armed at hand-off, not on resolution.)
|
|
1426
|
+
// a resume wake's continue was decided at planning time, before the claim/attachment
|
|
1437
1427
|
// awaits above; the episode may have ended (or a new one parked input) since. Re-validated in
|
|
1438
1428
|
// the FIFO slot at the offer instant: a continue that no longer holds is dropped and settled
|
|
1439
1429
|
// silently. Refused input parks inside the slot (below).
|
|
@@ -1443,7 +1433,7 @@ export class AgentRunner {
|
|
|
1443
1433
|
staleWakes.push(message);
|
|
1444
1434
|
return false;
|
|
1445
1435
|
}), { releaseClaims: true }), (heldIds) => {
|
|
1446
|
-
//
|
|
1436
|
+
// park refused input NOW, inside the slot: no accepted offer may end the episode first.
|
|
1447
1437
|
const held = new Set(heldIds);
|
|
1448
1438
|
for (const message of deliverable) {
|
|
1449
1439
|
if (!held.has(message.id) || isResumeWake(message)) continue;
|
|
@@ -1496,7 +1486,7 @@ export class AgentRunner {
|
|
|
1496
1486
|
this.drainingMessageIds = undefined;
|
|
1497
1487
|
this.delivering = false;
|
|
1498
1488
|
this.publishStatus();
|
|
1499
|
-
// Unaccepted input during a live turn waits for turnCleared, rather than reoffering on every tick
|
|
1489
|
+
// Unaccepted input during a live turn waits for turnCleared, rather than reoffering on every tick.
|
|
1500
1490
|
if (this.pendingMessages.size > 0 && (failed || drainBackoffMs > 0 || !this.currentTurnId)) this.scheduleDrain(failed ? Math.max(1000, drainBackoffMs) : drainBackoffMs);
|
|
1501
1491
|
}
|
|
1502
1492
|
}
|
|
@@ -1514,7 +1504,7 @@ export class AgentRunner {
|
|
|
1514
1504
|
if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
|
|
1515
1505
|
if (ticket) await ticket.turn;
|
|
1516
1506
|
await this.awaitContextCommandTurnClear("agent.clearContext");
|
|
1517
|
-
//
|
|
1507
|
+
// same new-generation reset as the compact arm above: cleared before the
|
|
1518
1508
|
// destructive step so any failure path still fails open toward re-delivery.
|
|
1519
1509
|
this.resetContextGeneration();
|
|
1520
1510
|
this.lateClearRecovery.begin(commandId, params, this.options.adapter.couplesClearInjectContinuation);
|
|
@@ -1522,15 +1512,15 @@ export class AgentRunner {
|
|
|
1522
1512
|
try { providerResult = await this.options.adapter.clearContext(this.process); }
|
|
1523
1513
|
finally { this.lateClearRecovery.finish(commandId, Boolean(providerResult && providerResult.clearConfirmed === true)); }
|
|
1524
1514
|
if (providerResult && providerResult.clearConfirmed === true) this.contextResets.confirmed("clear", commandId);
|
|
1525
|
-
//
|
|
1515
|
+
// F2 — commit barrier for the self-resume clear-inject path: a `/clear` whose commit
|
|
1526
1516
|
// could not be POSITIVELY confirmed is a possible no-op, so surface it as a FAILED command
|
|
1527
|
-
// (mirroring native's timeout->FAILED) instead of the
|
|
1517
|
+
// (mirroring native's timeout->FAILED) instead of the previous silent success. Throw
|
|
1528
1518
|
// BEFORE the coupled in-process deliver so we never inject a continuation into a context
|
|
1529
1519
|
// that may not have been cleared; the relay's continuation re-injection backstop (now also
|
|
1530
1520
|
// armed for a FAILED clear-inject) is then the single recovery path — a false-negative
|
|
1531
1521
|
// clear detection recovers rather than wiping working state.
|
|
1532
1522
|
this.assertClearInjectCommitted(params, providerResult || undefined);
|
|
1533
|
-
// The cleared context goes to the card now; the ~30s beat would leave the old usage visible meanwhile
|
|
1523
|
+
// The cleared context goes to the card now; the ~30s beat would leave the old usage visible meanwhile.
|
|
1534
1524
|
this.bus.flushHeartbeat();
|
|
1535
1525
|
// Before the continuation, so the next generation's first turn already sees the re-derived tools.
|
|
1536
1526
|
providerResult = { ...(providerResult || {}), tokenRemint: await this.remintRuntimeTokenAtBoundary("clear") };
|
|
@@ -1553,17 +1543,17 @@ export class AgentRunner {
|
|
|
1553
1543
|
// Fence the predecessor provider turn before a destructive compact/clearContext runs, so the
|
|
1554
1544
|
// successor generation never overlaps it. Two provider shapes need two different fences:
|
|
1555
1545
|
//
|
|
1556
|
-
//
|
|
1546
|
+
// F1 — a blind context-command submit (Claude's tmux `/compact` // `/clear`) must land on
|
|
1557
1547
|
// an IDLE prompt to parse as a slash command. Submitted while a turn is live it queues behind the
|
|
1558
|
-
// turn and dequeues as literal chat, so the compaction silently evaporates — the
|
|
1548
|
+
// turn and dequeues as literal chat, so the compaction silently evaporates — the runaway.
|
|
1559
1549
|
// For those adapters we WAIT for the live turn to clear on its own; a wait that times out THROWS,
|
|
1560
1550
|
// so the command fails loudly (F2/F4) rather than blind-submitting into a live turn and no-oping.
|
|
1561
1551
|
//
|
|
1562
|
-
//
|
|
1552
|
+
// an app-server adapter (Codex) whose clearContext starts a FRESH thread / whose compact
|
|
1563
1553
|
// rewrites the current one via an awaited handshake. The await confirms the NEW generation is
|
|
1564
|
-
// ready but does NOT settle the OLD turn:
|
|
1554
|
+
// ready but does NOT settle the OLD turn: humwick_compact_and_resume runs from within the model's
|
|
1565
1555
|
// own live turn, so that turn keeps writing the worktree on the previous thread while the
|
|
1566
|
-
// successor is armed on the new one — two generations revert each other (the
|
|
1556
|
+
// successor is armed on the new one — two generations revert each other (the silent
|
|
1567
1557
|
// overlap). A plain wait would time out on the still-live in-turn compaction, so we INTERRUPT the
|
|
1568
1558
|
// predecessor turn, then wait for it to reach a terminal state (interrupt-then-settle, mirroring
|
|
1569
1559
|
// the redirect path at the agent.interrupt arm) before the destructive step. A settle timeout
|
|
@@ -1581,7 +1571,7 @@ export class AgentRunner {
|
|
|
1581
1571
|
if (adapter.interruptsTurnBeforeContextCommand) {
|
|
1582
1572
|
const hasProviderTurn = this.claims.activeWork().some((work) => work.kind === "provider-turn");
|
|
1583
1573
|
if (!hasProviderTurn || !adapter.interrupt || !this.process) return;
|
|
1584
|
-
//
|
|
1574
|
+
// an interrupted turn ends with no output; that state has its own notice
|
|
1585
1575
|
// (agent.turn_interrupted) and must not also draw an empty-turn strike.
|
|
1586
1576
|
this.emptyTurns.reset();
|
|
1587
1577
|
try {
|
|
@@ -1600,21 +1590,21 @@ export class AgentRunner {
|
|
|
1600
1590
|
}
|
|
1601
1591
|
}
|
|
1602
1592
|
|
|
1603
|
-
//
|
|
1593
|
+
// F2 — the clear-inject commit barrier. A self-resume `/clear` whose commit the adapter's
|
|
1604
1594
|
// clear-aware barrier could not POSITIVELY confirm (clearConfirmed !== true) is treated as a
|
|
1605
|
-
// possible no-op and surfaced as a FAILED command, never the
|
|
1595
|
+
// possible no-op and surfaced as a FAILED command, never the previous silent success. Scoped to
|
|
1606
1596
|
// the coupled clear-inject path (couplesClearInjectContinuation) carrying a real continuation —
|
|
1607
1597
|
// a manual `/clear` with no self-resume payload keeps its existing best-effort semantics.
|
|
1608
1598
|
private assertClearInjectCommitted(params: Record<string, unknown>, clearResult: Record<string, unknown> | undefined): void {
|
|
1609
1599
|
assertClearInjectCommitted(this.options.adapter.couplesClearInjectContinuation, params, clearResult, (message) => this.sessionLog(message));
|
|
1610
1600
|
}
|
|
1611
1601
|
|
|
1612
|
-
//
|
|
1602
|
+
// couple the self-resume continuation delivery into the SAME clearContext command that
|
|
1613
1603
|
// wipes the context, so the two can never race across separate commands/transports again. The
|
|
1614
1604
|
// relay embeds the fully-built (identity-augmented) continuation in the clear command's
|
|
1615
1605
|
// `selfResume.continuation`; we deliver it in-process, strictly AFTER adapter.clearContext has
|
|
1616
1606
|
// returned. `continuationDelivered` tells the relay whether it can skip the redundant reactive
|
|
1617
|
-
// inject or must still fire its
|
|
1607
|
+
// inject or must still fire its backstop — and it MUST be truthful, because the relay skips
|
|
1618
1608
|
// BOTH backstops on continuationDelivered===true, so a false-positive true is a silent-wipe risk
|
|
1619
1609
|
// with no recovery. So `true` requires TWO things: (1) the adapter's clear-aware barrier
|
|
1620
1610
|
// POSITIVELY confirmed the TUI clear committed (clearResult.clearConfirmed === true — the strong
|
|
@@ -1637,7 +1627,7 @@ export class AgentRunner {
|
|
|
1637
1627
|
// truthful continuationDelivered:true. Absent/false (older runner, or a settle timeout) → false.
|
|
1638
1628
|
const clearConfirmed = clearResult?.clearConfirmed === true;
|
|
1639
1629
|
try {
|
|
1640
|
-
//
|
|
1630
|
+
// N3 — reuse the outer clearContext ticket instead of letting injectContext reserve a
|
|
1641
1631
|
// fresh one, which would queue behind (and thus deadlock on) the still-held outer ticket.
|
|
1642
1632
|
await this.injectContext({ content: continuation, reason: "self-resume-continuation", selfResume }, ticket);
|
|
1643
1633
|
if (!clearConfirmed) {
|
|
@@ -1668,14 +1658,14 @@ export class AgentRunner {
|
|
|
1668
1658
|
const fullContextMessageId = typeof selfResume?.fullContextMessageId === "number"
|
|
1669
1659
|
? selfResume.fullContextMessageId
|
|
1670
1660
|
: undefined;
|
|
1671
|
-
//
|
|
1661
|
+
// the one content-hash backstop for system-owned context injections. injectContext is
|
|
1672
1662
|
// the SINGLE chokepoint every such payload flows through (continuation, identity, memory
|
|
1673
1663
|
// preamble, startup context — all arrive as agent.injectContext commands), so one check here
|
|
1674
1664
|
// covers every path. Byte-identical match only, per current generation. Prompts and messages
|
|
1675
1665
|
// use injectPrompt/drainMessages and are never deduped here.
|
|
1676
1666
|
const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
1677
1667
|
if (this.injectedContextHashes.has(contentHash)) {
|
|
1678
|
-
this.sessionLog(`context injection (${reason ?? "no reason"}) skipped: byte-identical content already delivered this generation (
|
|
1668
|
+
this.sessionLog(`context injection (${reason ?? "no reason"}) skipped: byte-identical content already delivered this generation (backstop)`);
|
|
1679
1669
|
this.areaMemory.noteInjected(params, memoryIds);
|
|
1680
1670
|
return {
|
|
1681
1671
|
memoryIds,
|
|
@@ -1683,7 +1673,7 @@ export class AgentRunner {
|
|
|
1683
1673
|
duplicateInjectSkipped: true,
|
|
1684
1674
|
// Truthful settle evidence: the hash table IS the proof the payload reached this
|
|
1685
1675
|
// provider context, so a skipped continuation still reports ingested (the relay's
|
|
1686
|
-
//
|
|
1676
|
+
// fence and transport settle then close the boundary instead of re-arming).
|
|
1687
1677
|
...(isSelfResumeContinuation ? { continuationIngested: true, ...(fullContextMessageId !== undefined ? { fullContextMessageId } : {}) } : {}),
|
|
1688
1678
|
} as Record<string, unknown>;
|
|
1689
1679
|
}
|
|
@@ -1692,7 +1682,7 @@ export class AgentRunner {
|
|
|
1692
1682
|
from: "system",
|
|
1693
1683
|
to: this.agentId,
|
|
1694
1684
|
kind: "system",
|
|
1695
|
-
subject: isSelfResumeContinuation ? "
|
|
1685
|
+
subject: isSelfResumeContinuation ? "Humwick self-resume continuation" : "Humwick memory context",
|
|
1696
1686
|
body: content,
|
|
1697
1687
|
payload: {
|
|
1698
1688
|
memoryInjection: true,
|
|
@@ -1704,14 +1694,14 @@ export class AgentRunner {
|
|
|
1704
1694
|
readBy: [],
|
|
1705
1695
|
createdAt: Date.now(),
|
|
1706
1696
|
};
|
|
1707
|
-
//
|
|
1697
|
+
// F1 — one payload at a time, in dispatch order (see dispatchCommand). The ticket, when
|
|
1708
1698
|
// present, is the place in line this command took the moment it arrived.
|
|
1709
|
-
//
|
|
1699
|
+
// armed WATCH-ONLY. The turn after a memory block or a self-resume continuation
|
|
1710
1700
|
// legitimately produces nothing (a compaction, a silent acknowledgment), so it must take no
|
|
1711
1701
|
// strike and provoke no nudge — but a continuation that genuinely strands still has to wake
|
|
1712
1702
|
// someone, and the strike ladder can never see that. The timed backstop is what covers it.
|
|
1713
1703
|
const contextArm = this.emptyTurns.armInput(Date.now(), "watch-only", true);
|
|
1714
|
-
//
|
|
1704
|
+
// a context payload the provider REFUSED (a quota wall, a throttle) did not land. It
|
|
1715
1705
|
// parks in the hold machine like every other refused input (inbox-persisted, replayed by the
|
|
1716
1706
|
// resume wake — never a generic continue, which would run it from the provider's history AND
|
|
1717
1707
|
// again on replay). No hash, no `continuationIngested`, no fence release until the parked copy
|
|
@@ -1720,7 +1710,7 @@ export class AgentRunner {
|
|
|
1720
1710
|
// replays it (src/self-resume.ts). A parked continuation names its recovery artifact so the relay
|
|
1721
1711
|
// promotes it to ingested when the parked copy is delivered.
|
|
1722
1712
|
let parkedKey: string | undefined;
|
|
1723
|
-
//
|
|
1713
|
+
// decided inside the provider-input slot on every attempt: a reset queued ahead of this payload runs first.
|
|
1724
1714
|
let staleArea = false;
|
|
1725
1715
|
const deliver = (slot?: ProviderInputTicket) => this.offerMessagesToProvider(() => (staleArea = this.areaMemory.isStaleInjection(params)) ? [] : [payload], () => {
|
|
1726
1716
|
parkedKey = this.parkRefusedPrompt(content, isSelfResumeContinuation && fullContextMessageId !== undefined ? { selfResumeFullContextMessageId: fullContextMessageId } : {});
|
|
@@ -1730,7 +1720,7 @@ export class AgentRunner {
|
|
|
1730
1720
|
await deliver(ticket);
|
|
1731
1721
|
} catch (error) {
|
|
1732
1722
|
// Codex has no input buffer: a payload offered during a live turn is rejected rather than
|
|
1733
|
-
// queued
|
|
1723
|
+
// queued. Serializing the boundary pair means the SECOND payload now meets the turn
|
|
1734
1724
|
// the first one started, so re-offer it once the turn ends instead of failing the command —
|
|
1735
1725
|
// the same queue-behind-the-turn semantics Claude's transports get for free from the CLI's
|
|
1736
1726
|
// own buffer. A timeout waiting for the turn re-throws: the command fails, the memory
|
|
@@ -1792,7 +1782,7 @@ export class AgentRunner {
|
|
|
1792
1782
|
return allowed;
|
|
1793
1783
|
}
|
|
1794
1784
|
|
|
1795
|
-
/**
|
|
1785
|
+
/** a new provider context generation: charter/memory may land again, every path area is unentered. */
|
|
1796
1786
|
private resetContextGeneration(): void { this.injectedContextHashes.clear(); this.areaMemory.resetGeneration(); }
|
|
1797
1787
|
|
|
1798
1788
|
private recoverLateClear(input: { source?: string; providerSessionId?: string }): string | undefined {
|
|
@@ -1835,7 +1825,7 @@ export class AgentRunner {
|
|
|
1835
1825
|
}
|
|
1836
1826
|
}
|
|
1837
1827
|
this.lifecycleMilestones.onRestart("work-cleared");
|
|
1838
|
-
//
|
|
1828
|
+
// also honor a shutdown that began mid-restart (signal-driven, before stop itself
|
|
1839
1829
|
// has run); this.lifecycle.stopped alone lags stop()'s own entry.
|
|
1840
1830
|
if (this.lifecycle.stopped || this.lifecycle.shuttingDown) return;
|
|
1841
1831
|
this.adoptProcess(await this.spawnProvider(opts));
|
|
@@ -1859,7 +1849,7 @@ export class AgentRunner {
|
|
|
1859
1849
|
private shouldRestartUnexpectedProviderExit(status: SemanticStatus): boolean {
|
|
1860
1850
|
return runnerShouldRestartUnexpectedProviderExit(status, {
|
|
1861
1851
|
exitCommandInProgress: this.lifecycle.exitCommandInProgress,
|
|
1862
|
-
//
|
|
1852
|
+
// this is the actual restart-suppression race: `shuttingDown` (armed synchronously
|
|
1863
1853
|
// from the signal handler) must win it even before stop() itself has run and set `stopped`.
|
|
1864
1854
|
stopped: this.lifecycle.stopped || this.lifecycle.shuttingDown,
|
|
1865
1855
|
restartInProgress: this.lifecycle.restartInProgress,
|
|
@@ -1874,7 +1864,7 @@ export class AgentRunner {
|
|
|
1874
1864
|
if (this.lifecycle.restartPending) return;
|
|
1875
1865
|
this.lifecycle.beginUnexpectedExitRestart();
|
|
1876
1866
|
try {
|
|
1877
|
-
// Best-effort Insights capture for the segment that just ended in a crash
|
|
1867
|
+
// Best-effort Insights capture for the segment that just ended in a crash. This
|
|
1878
1868
|
// path has no controlled teardown, so without it crashed sessions silently drop their
|
|
1879
1869
|
// context-ratio datapoint. The process handle is still set (cleared later), so the
|
|
1880
1870
|
// provider capture source is readable; the runner stays alive here (restart or offline), so the
|
|
@@ -1947,7 +1937,7 @@ export class AgentRunner {
|
|
|
1947
1937
|
if (!hasResumeId && this.shouldRestartRecoverableProviderExit(diagnostics)) {
|
|
1948
1938
|
logger.warn("lifecycle", `${this.options.provider} exit signal is early/recoverable; allowing fresh restart`);
|
|
1949
1939
|
} else if (resumeId && this.shouldResumeWithContextProviderExit(diagnostics, recent.length)) {
|
|
1950
|
-
//
|
|
1940
|
+
// (L2): a long-lived agent killed via tmux-session-ended (external SIGTERM, reboot,
|
|
1951
1941
|
// OOM, admin cleanup) used to be stranded permanently offline even though the provider CLI printed
|
|
1952
1942
|
// a resumable session id. Now, when Layer 3 captured a valid resume id, auto-resume WITH
|
|
1953
1943
|
// context instead of surfacing manual recovery. Crash-loop protection reuses the existing
|
|
@@ -1974,10 +1964,10 @@ export class AgentRunner {
|
|
|
1974
1964
|
},
|
|
1975
1965
|
});
|
|
1976
1966
|
this.process = undefined;
|
|
1977
|
-
//
|
|
1967
|
+
// the runner stays alive (manual-resume hold), so neither the orchestrator tmux-gone
|
|
1978
1968
|
// path nor the heartbeat reaper will ever fire. Stamp the terminal marker so the SAME offline
|
|
1979
1969
|
// status frame (published by setProviderStatus below) carries it; the server's bus status
|
|
1980
|
-
// handler turns that edge into a terminal `agent.exited` event with a
|
|
1970
|
+
// handler turns that edge into a terminal `agent.exited` event with a diagnosis.
|
|
1981
1971
|
this.terminalProviderExit = {
|
|
1982
1972
|
providerSessionId: this.providerSessionId,
|
|
1983
1973
|
reason: providerManualRecoveryReason(diagnostics),
|
|
@@ -2042,7 +2032,7 @@ export class AgentRunner {
|
|
|
2042
2032
|
await Bun.sleep(delayMs);
|
|
2043
2033
|
if (this.lifecycle.stopped || this.lifecycle.exitCommandInProgress) return;
|
|
2044
2034
|
try {
|
|
2045
|
-
//
|
|
2035
|
+
// /#harden — a resume relaunch always carries a resumeId here, so passing
|
|
2046
2036
|
// suppressPrompt would be a no-op (it's only consumed under !resumeId, see spawnProvider).
|
|
2047
2037
|
// What actually keeps a skip-permissions resume off the interactive "Bypass Permissions
|
|
2048
2038
|
// mode" gate: the config-seed re-heal in profile-home.ts, which idempotently re-applies
|
|
@@ -2073,7 +2063,7 @@ export class AgentRunner {
|
|
|
2073
2063
|
&& diagnostics.runtimeMs < RAPID_EXIT_MS;
|
|
2074
2064
|
}
|
|
2075
2065
|
|
|
2076
|
-
//
|
|
2066
|
+
// (L2): auto-resume-with-context gate for a tmux-session-ended death at any point in an
|
|
2077
2067
|
// agent's life (not just the <30s early-flake window). Requires a captured resume id (the caller
|
|
2078
2068
|
// only reaches this when one exists) and bounds crash loops via the shared unexpected-exit window:
|
|
2079
2069
|
// once `recent` exceeds MAX_RAPID_UNEXPECTED_EXITS the agent is left offline for manual recovery.
|
|
@@ -2099,11 +2089,11 @@ export class AgentRunner {
|
|
|
2099
2089
|
|
|
2100
2090
|
private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<ShutdownOutcome> {
|
|
2101
2091
|
// Close provider-owned status sources before the graceful await, while keeping the runner's
|
|
2102
|
-
// own transitional projection live until the adapter confirms the outcome
|
|
2092
|
+
// own transitional projection live until the adapter confirms the outcome.
|
|
2103
2093
|
this.lifecycle.armTerminal();
|
|
2104
2094
|
this.lifecycle.setLifecycleAction(hard ? "killing" : "shutting-down");
|
|
2105
2095
|
this.publishStatus();
|
|
2106
|
-
//
|
|
2096
|
+
// no process to kill is vacuously terminal. Otherwise the adapter now returns a
|
|
2107
2097
|
// VERIFIED terminal outcome (it re-observes liveness, it does not trust the kill call);
|
|
2108
2098
|
// a legacy adapter that returns void is treated as `killed` for back-compat.
|
|
2109
2099
|
let outcome: ShutdownOutcome = "killed";
|
|
@@ -2169,7 +2159,7 @@ export class AgentRunner {
|
|
|
2169
2159
|
};
|
|
2170
2160
|
}
|
|
2171
2161
|
let deferredRateLimitHold = false, openQueuedTurn = false;
|
|
2172
|
-
//
|
|
2162
|
+
// a turn the provider REFUSED (it ends in a rate-limit hold) did not run: it is neither
|
|
2173
2163
|
// an empty turn to nudge (the nudge would hit the same wall) nor a completion of claimed work.
|
|
2174
2164
|
// The relay's resume turn is the one that gets judged/credited.
|
|
2175
2165
|
const providerRefusedTurn = status === "idle" && typeof update !== "string" && update.providerState?.state === "blocked" && update.providerState.reason === "rate_limit";
|
|
@@ -2196,7 +2186,7 @@ export class AgentRunner {
|
|
|
2196
2186
|
if (fresh) this.publishRateLimitNotice(update.providerState);
|
|
2197
2187
|
}
|
|
2198
2188
|
} else if (ps.state === "blocked" && ps.reason === "pendingQuestion") {
|
|
2199
|
-
//
|
|
2189
|
+
// set directly (never deferred) — unlike the rate-limit hold, this always
|
|
2200
2190
|
// arrives while the turn that asked is still busy, well before the real idle it must
|
|
2201
2191
|
// survive. Any other providerState (a fresh question, a plain approval, etc.) still
|
|
2202
2192
|
// supersedes it via the `else` branch below.
|
|
@@ -2242,7 +2232,7 @@ export class AgentRunner {
|
|
|
2242
2232
|
if (timelineStatus === "compacting") {
|
|
2243
2233
|
this.compactionMidTurn = this.currentTurnId !== undefined;
|
|
2244
2234
|
} else if (timelineStatus === "compacted") {
|
|
2245
|
-
//
|
|
2235
|
+
// a native compaction drops what area memory delivered, not what the dedupe table proves.
|
|
2246
2236
|
this.areaMemory.resetGeneration();
|
|
2247
2237
|
this.nativeSelfResume.noteCompacted(typeof update !== "string" && typeof update.timeline?.timestamp === "number" ? update.timeline.timestamp : Date.now());
|
|
2248
2238
|
this.publishCompactionNotice();
|
|
@@ -2255,7 +2245,7 @@ export class AgentRunner {
|
|
|
2255
2245
|
}
|
|
2256
2246
|
}
|
|
2257
2247
|
if (status === "busy" && reason === "provider-turn") {
|
|
2258
|
-
//
|
|
2248
|
+
// a launch-seeded, monitorless Claude turn can discover its transcript only after
|
|
2259
2249
|
// the initial synthetic busy edge: Claude writes sessions/<pid>.json asynchronously after
|
|
2260
2250
|
// its launcher gate opens. Accept a path on every busy edge, not just the edge that mints
|
|
2261
2251
|
// the turn, so the terminal drain still knows where to settle the one/last turn's usage.
|
|
@@ -2271,8 +2261,8 @@ export class AgentRunner {
|
|
|
2271
2261
|
this.emptyTurns.noteTurnStarted(this.currentTurnId, this.currentTurnStartedAt);
|
|
2272
2262
|
this.turnReport.noteTurnStarted(this.currentTurnId);
|
|
2273
2263
|
this.sessionLog(`turn started (turn ${this.currentTurnId})`);
|
|
2274
|
-
//
|
|
2275
|
-
//
|
|
2264
|
+
// never clear pendingQuestionHold here — the busy edge that sets it can mint a turn.
|
|
2265
|
+
// a hookless (monitorless) adapter turn carries its live transcript path on the
|
|
2276
2266
|
// busy edge. Drive the reasoning tail natively so lean/isolated Claude workers, which
|
|
2277
2267
|
// never install the Stop/UserPromptSubmit hooks, still stream chat. Hook-driven agents
|
|
2278
2268
|
// never set transcriptPath here, so their hook-triggered capture is untouched.
|
|
@@ -2297,7 +2287,7 @@ export class AgentRunner {
|
|
|
2297
2287
|
if (this.currentTurnId) {
|
|
2298
2288
|
this.completedProviderTurns += 1;
|
|
2299
2289
|
this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
|
|
2300
|
-
//
|
|
2290
|
+
// "the provider says idle" is NOT the same as "the turn did something". Take the
|
|
2301
2291
|
// verdict after a settle delay, because several capture paths publish the closing response
|
|
2302
2292
|
// asynchronously, after this edge.
|
|
2303
2293
|
//
|
|
@@ -2308,7 +2298,7 @@ export class AgentRunner {
|
|
|
2308
2298
|
if (timelineStatus === "compacted" || timelineStatus === "context-cleared" || this.compactionMidTurn || providerRefusedTurn) this.emptyTurns.noteTurnDiscarded(this.currentTurnId);
|
|
2309
2299
|
else this.emptyTurns.noteTurnEnded(this.currentTurnId, Date.now());
|
|
2310
2300
|
}
|
|
2311
|
-
//
|
|
2301
|
+
// for a native (hookless) turn, capture the final assistant response from the
|
|
2312
2302
|
// transcript BEFORE tearing down the tail. Snapshot the ctx while currentTurnId is still
|
|
2313
2303
|
// set so the closing response is tagged with the correct turn.
|
|
2314
2304
|
const nativeFinish = this.sessionMirror.nativeCaptureActive() || Boolean(lateNativeTranscriptPath);
|
|
@@ -2326,17 +2316,17 @@ export class AgentRunner {
|
|
|
2326
2316
|
}
|
|
2327
2317
|
// Providers with append-system-prompt policy use lifecycle hooks for obligation handling;
|
|
2328
2318
|
// others need the runner to re-inject pending obligations on idle.
|
|
2329
|
-
//
|
|
2319
|
+
// this delivery can suspend on the FIFO and, once resumed, reject if
|
|
2330
2320
|
// the process died out from under it or the adapter is mid-turn; reported the same way
|
|
2331
2321
|
// attemptInitialPromptDelivery reports its own adapter.deliver failures, so it can never
|
|
2332
2322
|
// reach the top as an unhandled rejection (Bun exits(1) on those) and never vanish silently.
|
|
2333
|
-
//
|
|
2323
|
+
// folded onto the shared reportBackgroundRejection shape — same channel, same
|
|
2334
2324
|
// status/title/body, one less hand-rolled copy of the reporting block.
|
|
2335
2325
|
if (getManifest(this.options.provider)?.launch?.managedPolicyPrompt?.alwaysOn !== "append-system-prompt") {
|
|
2336
2326
|
void this.reInjectPendingObligation().catch((error) => this.reportBackgroundRejection("re-inject pending obligation", "obligation.reinject_failed", "Pending reply obligation not re-injected", error));
|
|
2337
2327
|
}
|
|
2338
2328
|
}
|
|
2339
|
-
//
|
|
2329
|
+
// work of any other kind starting inside a live turn (a background script, tracked
|
|
2340
2330
|
// subprocess work) is independent proof the model acted, even if nothing was ever mirrored.
|
|
2341
2331
|
if (status === "busy" && reason !== "provider-turn" && this.currentTurnId) this.emptyTurns.noteOutput();
|
|
2342
2332
|
// A genuine restart back to forward progress clears the terminal-exit marker so a later offline
|
|
@@ -2344,13 +2334,13 @@ export class AgentRunner {
|
|
|
2344
2334
|
if (status === "busy" || status === "idle") this.terminalProviderExit = undefined;
|
|
2345
2335
|
if (status === "busy") {
|
|
2346
2336
|
this.claims.clearTerminalStatus();
|
|
2347
|
-
//
|
|
2337
|
+
// liveness-track the pids a reporter explicitly supplied; otherwise, for a
|
|
2348
2338
|
// provider-turn, fall back to the managed provider process itself. If that process
|
|
2349
2339
|
// dies out-of-band with no exit event reaching the runner (crash/orphan/external
|
|
2350
2340
|
// kill), the reconcile clears the stuck turn → idle. A dead pid means the turn is
|
|
2351
|
-
// definitively over, so this can never falsely clear a live turn
|
|
2341
|
+
// definitively over, so this can never falsely clear a live turn.
|
|
2352
2342
|
const reportedPids = typeof update === "string" ? undefined : update.pids;
|
|
2353
|
-
//
|
|
2343
|
+
// process.pid is undefined for tmux-launched providers (Claude) — only
|
|
2354
2344
|
// the adapter can resolve a real pid from the tmux session/socket. Without this,
|
|
2355
2345
|
// an ordinary hook-driven turn tracks no pid at all, so the stale-turn backstop
|
|
2356
2346
|
// below has nothing but elapsed time to go on.
|
|
@@ -2368,7 +2358,7 @@ export class AgentRunner {
|
|
|
2368
2358
|
metadata: {
|
|
2369
2359
|
...(update.metadata ?? {}),
|
|
2370
2360
|
...(update.providerState ? { providerState: update.providerState } : {}),
|
|
2371
|
-
//
|
|
2361
|
+
// stamp/preserve each background task's startedAt — the hook
|
|
2372
2362
|
// can only ever report the current snapshot, so age has to be tracked here,
|
|
2373
2363
|
// against the prior background-script work record. Pure merge lives in
|
|
2374
2364
|
// background-script-reconcile.ts beside this record's other reconcile logic.
|
|
@@ -2378,7 +2368,7 @@ export class AgentRunner {
|
|
|
2378
2368
|
},
|
|
2379
2369
|
});
|
|
2380
2370
|
// A busy report with liveness-trackable pids arms the periodic reconcile (self-disarms
|
|
2381
|
-
// once no pid-bearing work remains, so idle/untracked agents pay nothing).
|
|
2371
|
+
// once no pid-bearing work remains, so idle/untracked agents pay nothing). — a
|
|
2382
2372
|
// `background-script` busy also arms it, even with no pids: it is the ONLY signal that ever
|
|
2383
2373
|
// runs the no-pid stale backstop that unwedges a leftover background-script (Claude's hook
|
|
2384
2374
|
// can never supply pids for CC background tasks).
|
|
@@ -2392,7 +2382,7 @@ export class AgentRunner {
|
|
|
2392
2382
|
else this.claims.finishWork(reason, id);
|
|
2393
2383
|
if (reason === "provider-turn" && !providerRefusedTurn) {
|
|
2394
2384
|
this.taskClaims.completeObservedTaskClaims().catch((error) => logger.error("task", `completion enqueue failed: ${error}`));
|
|
2395
|
-
//
|
|
2385
|
+
// the provider took a turn: the hold episode ends, parked input goes out. Not while
|
|
2396
2386
|
// a hold is still standing or pending (a pane-detected wall can land before its turn's idle):
|
|
2397
2387
|
// the published hold names this episode and its wake must still act.
|
|
2398
2388
|
if (!this.rateLimitHold && !this.pendingRateLimitHold) {
|
|
@@ -2407,7 +2397,7 @@ export class AgentRunner {
|
|
|
2407
2397
|
for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
|
|
2408
2398
|
}
|
|
2409
2399
|
if (!this.claims.activeWork().some((work) => work.kind === "provider-turn")) this.busyReconciler.providerTurnCleared();
|
|
2410
|
-
//
|
|
2400
|
+
// the provider just went idle (ready). If a first initial-prompt delivery timed out on
|
|
2411
2401
|
// a cold-start TUI, re-attempt now — the ready wait should resolve almost immediately.
|
|
2412
2402
|
if (status === "idle") this.promptCommands.retryInitialPrompt();
|
|
2413
2403
|
if (status === "idle" && this.pendingRateLimitHold && !this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
|
|
@@ -2418,7 +2408,7 @@ export class AgentRunner {
|
|
|
2418
2408
|
this.rateLimitHold = { ...hold, holdEpisodeId: this.hold.refused() };
|
|
2419
2409
|
if (fresh) this.publishRateLimitNotice(hold);
|
|
2420
2410
|
}
|
|
2421
|
-
//
|
|
2411
|
+
// native self-resume is invoked by the agent itself mid-turn (a tool call from
|
|
2422
2412
|
// within its own active response), so the very next idle is almost always the CALLING
|
|
2423
2413
|
// turn's own natural end — long before Claude Code even starts processing the queued
|
|
2424
2414
|
// `/compact <continuation>`. Resolving on any idle here raced that unrelated end against
|
|
@@ -2433,9 +2423,9 @@ export class AgentRunner {
|
|
|
2433
2423
|
this.publishStatus();
|
|
2434
2424
|
}
|
|
2435
2425
|
|
|
2436
|
-
//
|
|
2426
|
+
// Claude runs a prompt it queued during the turn's end without a UserPromptSubmit of its own.
|
|
2437
2427
|
// The ended turn is settled above; mint the queued turn now. Its tail starts with no turn id so its
|
|
2438
|
-
// steps carry Claude's fresh prompt id, the id its final gets from the Stop
|
|
2428
|
+
// steps carry Claude's fresh prompt id, the id its final gets from the Stop.
|
|
2439
2429
|
private openQueuedTurn(): void {
|
|
2440
2430
|
this.sessionLog("prompt queued behind the turn end — opening its turn");
|
|
2441
2431
|
this.setProviderStatus({ status: "busy", reason: "provider-turn" });
|
|
@@ -2460,22 +2450,22 @@ export class AgentRunner {
|
|
|
2460
2450
|
return true;
|
|
2461
2451
|
}
|
|
2462
2452
|
|
|
2463
|
-
//
|
|
2453
|
+
// when a provider finishes a turn with a pending spawner obligation still
|
|
2464
2454
|
// unresolved, re-inject the obligation as a synthetic message so providerMessageText
|
|
2465
2455
|
// surfaces the specific `humwick /reply <id>` command.
|
|
2466
2456
|
private async reInjectPendingObligation(): Promise<void> {
|
|
2467
2457
|
// Filter to spawner obligations only — user obligations go through the existing
|
|
2468
|
-
// prompt-capture path (publishProviderSessionEvent) and the session mirror
|
|
2458
|
+
// prompt-capture path (publishProviderSessionEvent) and the session mirror, never
|
|
2469
2459
|
// an explicit /reply. Shared predicate so this rule stays in lockstep with
|
|
2470
2460
|
// the stop-hook nag exemption (control-server.replyObligationStopDecision).
|
|
2471
2461
|
const obligations = (await this.obligationCache.freshForNag()).filter(obligationNagEligible);
|
|
2472
2462
|
if (!this.process) return;
|
|
2473
|
-
//
|
|
2463
|
+
// no re-injection into a provider hold: it would only be refused.
|
|
2474
2464
|
if (this.rateLimitHold || this.hold.activeEpisode) return;
|
|
2475
2465
|
if (!obligations.length) return;
|
|
2476
2466
|
const MAX_REINJECTIONS = 3;
|
|
2477
2467
|
for (const obligation of obligations) {
|
|
2478
|
-
//
|
|
2468
|
+
// the obligation carries the server's delivery decision. A non-`turn` obligation (wake:false
|
|
2479
2469
|
// / session) must never be re-surfaced as a turn nudge: this re-injection path builds a synthetic
|
|
2480
2470
|
// frame and calls `adapter.deliver` directly, BYPASSING the fanout seam, so the decision that would
|
|
2481
2471
|
// otherwise be lost to the `payload: {}` reconstruction is honored explicitly here instead.
|
|
@@ -2504,8 +2494,8 @@ export class AgentRunner {
|
|
|
2504
2494
|
// by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
|
|
2505
2495
|
// defense-in-depth for adapters that might echo only the unformatted body text.
|
|
2506
2496
|
this.recordInjectedPrompt(providerMessageText([replayMessage]));
|
|
2507
|
-
//
|
|
2508
|
-
//
|
|
2497
|
+
// N1 — route through the FIFO like every other deliver/deliverInitialPrompt call site.
|
|
2498
|
+
// a re-injected obligation is the runner asking for work the agent still owes, so it
|
|
2509
2499
|
// arms — but as a RETRY, not a fresh ladder arm. This fires up to MAX_REINJECTIONS times per
|
|
2510
2500
|
// message; opening a new strike budget each time would let one message earn four nudges and
|
|
2511
2501
|
// keep consecutiveEmpty below the escalation threshold indefinitely, on exactly the agents
|
|
@@ -2513,7 +2503,7 @@ export class AgentRunner {
|
|
|
2513
2503
|
const arm = this.emptyTurns.armInput(Date.now(), "retry", true);
|
|
2514
2504
|
let held = false;
|
|
2515
2505
|
try {
|
|
2516
|
-
//
|
|
2506
|
+
// a refused re-injection spends no attempt and leaves the obligation standing.
|
|
2517
2507
|
await this.offerMessagesToProvider(() => [replayMessage], () => {
|
|
2518
2508
|
held = true;
|
|
2519
2509
|
this.pendingObligationInjections.set(obligation.messageId, count);
|
|
@@ -2536,9 +2526,9 @@ export class AgentRunner {
|
|
|
2536
2526
|
}
|
|
2537
2527
|
}
|
|
2538
2528
|
|
|
2539
|
-
// A hook reported an unhandled failure
|
|
2529
|
+
// A hook reported an unhandled failure. Already logged FATAL by the control
|
|
2540
2530
|
// server; here we additionally surface it durably to the server as a generic insight so
|
|
2541
|
-
// it shows up in observability rather than only in the per-agent log
|
|
2531
|
+
// it shows up in observability rather than only in the per-agent log.
|
|
2542
2532
|
private reportHookFatal(report: { hook: string; error: string }): void {
|
|
2543
2533
|
try {
|
|
2544
2534
|
this.insights.queueHookFatal(report);
|
|
@@ -2592,7 +2582,7 @@ export class AgentRunner {
|
|
|
2592
2582
|
const work = this.claims.activeWork().find((w) => w.kind === "provider-turn");
|
|
2593
2583
|
if (!work) return;
|
|
2594
2584
|
this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
|
|
2595
|
-
//
|
|
2585
|
+
// the OTHER producer of `agent.turn_interrupted`, and the same reason the interrupt
|
|
2596
2586
|
// command resets: a force-cleared turn ended with no output through no fault of the model, and
|
|
2597
2587
|
// that state already has its own notice. Resetting also drops the turn record this path would
|
|
2598
2588
|
// otherwise leave open forever, which would wedge the timed backstop.
|
|
@@ -2603,12 +2593,12 @@ export class AgentRunner {
|
|
|
2603
2593
|
this.busyReconciler.providerTurnCleared();
|
|
2604
2594
|
this.currentTurnId = undefined;
|
|
2605
2595
|
this.compactionMidTurn = false;
|
|
2606
|
-
//
|
|
2596
|
+
// the clear is EMITTED, not silently applied — publishRunnerTimelineEvent publishes
|
|
2607
2597
|
// the status too, so the idle edge and its explanation travel in the same frame.
|
|
2608
2598
|
this.publishRunnerTimelineEvent(event);
|
|
2609
2599
|
}
|
|
2610
2600
|
|
|
2611
|
-
//
|
|
2601
|
+
// the recovery. NOT a re-send: the instruction is already in the provider's session
|
|
2612
2602
|
// history (which is why the stranded agent ran its original spawn prompt the moment a human typed
|
|
2613
2603
|
// "hello" at it hours later), so this carries no copy of it. Delivered with origin "runner" so it
|
|
2614
2604
|
// does NOT arm the ledger — a nudge that armed a fresh nudge budget would loop without bound
|
|
@@ -2633,7 +2623,7 @@ export class AgentRunner {
|
|
|
2633
2623
|
}
|
|
2634
2624
|
}
|
|
2635
2625
|
|
|
2636
|
-
//
|
|
2626
|
+
// U2 — the busy backstop has HELD a live provider turn past its prolonged-hold threshold
|
|
2637
2627
|
// instead of cancelling it (a frozen-active `shell` gate, or a pane-scrape-only long tool the
|
|
2638
2628
|
// runner cannot positively prove idle). Emit a NOTIFY-ONLY watchdog: the turn is untouched (the
|
|
2639
2629
|
// card stays busy, no work-kind is cleared, currentTurnId is preserved) — this only tells a
|
|
@@ -2650,13 +2640,13 @@ export class AgentRunner {
|
|
|
2650
2640
|
}));
|
|
2651
2641
|
}
|
|
2652
2642
|
|
|
2653
|
-
//
|
|
2643
|
+
// reconcile pid-bearing busy work against actual process liveness. A tracked
|
|
2654
2644
|
// command/shell that dies out-of-band (killed by the operator, crashes, orphaned)
|
|
2655
2645
|
// sends no exit event, so its busy marker would otherwise never clear and the agent
|
|
2656
2646
|
// shows `busy` forever — blocking transient auto-reap and holding provider quota.
|
|
2657
|
-
//
|
|
2647
|
+
// this timer ALSO runs the no-pid `background-script` backstop: Claude's Stop
|
|
2658
2648
|
// hook can only post a pid-less `background-script` busy (its `background_tasks[]` payload
|
|
2659
|
-
// exposes no OS pid), which
|
|
2649
|
+
// exposes no OS pid), which the pid reconcile can never touch. So the timer is armed for
|
|
2660
2650
|
// ANY background-script busy too, and stays armed while one exists. It self-disarms once
|
|
2661
2651
|
// neither pid-bearing work nor a background-script busy remains, so idle agents pay nothing.
|
|
2662
2652
|
private armBusyLivenessReconcile(): void {
|
|
@@ -2690,7 +2680,7 @@ export class AgentRunner {
|
|
|
2690
2680
|
this.busyReconciler.providerTurnCleared();
|
|
2691
2681
|
}
|
|
2692
2682
|
}
|
|
2693
|
-
//
|
|
2683
|
+
// the no-pid backstop: a leftover `background-script` busy that outlived its work
|
|
2694
2684
|
// (background task finished or was killed manually) leaves the agent wedged busy forever,
|
|
2695
2685
|
// because it carries no pid to reconcile and only the next turn's Stop hook would clear it.
|
|
2696
2686
|
// Once the busy state is background-script ALONE (the provider turn already ended) and has
|
|
@@ -2736,7 +2726,7 @@ export class AgentRunner {
|
|
|
2736
2726
|
});
|
|
2737
2727
|
}
|
|
2738
2728
|
|
|
2739
|
-
//
|
|
2729
|
+
// a discreet, durable chat marker when a usage/rate-limit hold begins, via
|
|
2740
2730
|
// the same session-mirror lane as the compaction notice. Outbound session event
|
|
2741
2731
|
// (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn
|
|
2742
2732
|
// — waking a still-limited agent would just re-fail. The relay sends the waking
|
|
@@ -2761,10 +2751,10 @@ export class AgentRunner {
|
|
|
2761
2751
|
if (activeWork.length === 0 || activeWork.some((work) => work.kind !== "provider-turn")) return false;
|
|
2762
2752
|
if (busyReasons.some((reason) => reason !== "provider-turn")) return false;
|
|
2763
2753
|
if (liveness.hasLiveWork || !liveness.lastProgressAt) return false;
|
|
2764
|
-
//
|
|
2754
|
+
// a turn whose tracked pid is still alive is definitively still running — a
|
|
2765
2755
|
// silent single Claude turn (no subagents, no compaction event) must not be
|
|
2766
2756
|
// force-healed to idle purely because it's been quiet past the threshold. Only fall
|
|
2767
|
-
// back to the elapsed-time heuristic when there's no pid evidence to check; the
|
|
2757
|
+
// back to the elapsed-time heuristic when there's no pid evidence to check; the
|
|
2768
2758
|
// reconcile independently clears a confirmed-dead pid's work the instant it dies, so
|
|
2769
2759
|
// this can never mask a genuine leak.
|
|
2770
2760
|
if (activeWork.some((work) => work.pids?.some((pid) => isPidAlive(pid)))) return false;
|
|
@@ -2779,16 +2769,16 @@ export class AgentRunner {
|
|
|
2779
2769
|
const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
|
|
2780
2770
|
const terminalFailure = this.terminalFailure;
|
|
2781
2771
|
const timelineEvent = this.pendingTimelineEvent;
|
|
2782
|
-
// §4.1 Runner↔provider liveness contract
|
|
2772
|
+
// §4.1 Runner↔provider liveness contract: fold the raw transport/timeline/
|
|
2783
2773
|
// token/work-kind signals into ONE normalized verdict the server reads instead of six raw
|
|
2784
|
-
// `meta` fields. The adapter owns what counts as progress for its provider
|
|
2774
|
+
// `meta` fields. The adapter owns what counts as progress for its provider;
|
|
2785
2775
|
// mock adapters fall back to the shared fold.
|
|
2786
2776
|
const livenessInputs: LivenessInputs = {
|
|
2787
2777
|
transport: this.bus.transportState,
|
|
2788
2778
|
...(this.lastProviderEventAt !== undefined ? { lastProviderEventAt: this.lastProviderEventAt } : {}),
|
|
2789
2779
|
...(timelineEvent ? { timelineEvent } : {}),
|
|
2790
2780
|
activeWork,
|
|
2791
|
-
//
|
|
2781
|
+
// U3 — feed the runner's coarse status + provider-hold flag so the fold can derive
|
|
2792
2782
|
// busyPhase (active/passive/blocked) beside hasLiveWork, provider-agnostically.
|
|
2793
2783
|
providerStatus: status,
|
|
2794
2784
|
providerBlocked: this.providerBlocked || this.startupPaneDialog !== undefined || this.deliverySubmitHold !== undefined,
|
|
@@ -2808,7 +2798,7 @@ export class AgentRunner {
|
|
|
2808
2798
|
const agentStatus = teardownLifecycle && effectiveStatus !== "offline" && effectiveStatus !== "error"
|
|
2809
2799
|
? "idle"
|
|
2810
2800
|
: runnerAgentStatus(effectiveStatus);
|
|
2811
|
-
//
|
|
2801
|
+
// `emptyTurnState` is the LAST fallback: a live hold, a pending question or real active
|
|
2812
2802
|
// work all describe the agent better than "the previous turn was empty", and any of them means
|
|
2813
2803
|
// it is no longer sitting idle having produced nothing.
|
|
2814
2804
|
const providerState = this.deliverySubmitHold ?? terminalFailure?.providerState ?? this.startupPaneDialog ?? this.rateLimitHold ?? this.pendingQuestionHold
|
|
@@ -2827,30 +2817,30 @@ export class AgentRunner {
|
|
|
2827
2817
|
terminalFailureReason: terminalFailure.reason,
|
|
2828
2818
|
terminalFailureMessage: terminalFailure.message,
|
|
2829
2819
|
} : {}),
|
|
2830
|
-
//
|
|
2831
|
-
// silent-death terminal event +
|
|
2820
|
+
// terminal provider-exit marker (runner alive, provider gone). Drives the server's
|
|
2821
|
+
// silent-death terminal event + post-mortem.
|
|
2832
2822
|
...(this.terminalProviderExit ? {
|
|
2833
2823
|
terminalProviderExit: this.terminalProviderExit,
|
|
2834
2824
|
lastError: providerExitLastError(this.options.provider, this.terminalProviderExit),
|
|
2835
2825
|
} : {}),
|
|
2836
2826
|
busyReasons: staleProviderTurnBusy ? busyReasons.filter((reason) => reason !== "provider-turn") : busyReasons,
|
|
2837
2827
|
completedProviderTurns: this.completedProviderTurns,
|
|
2838
|
-
//
|
|
2828
|
+
// machine-readable beside the displayed providerState, so a coordinator polling the
|
|
2839
2829
|
// roster can tell "idle having produced nothing" from "idle having finished work" without
|
|
2840
2830
|
// reading the timeline. 0 once any turn produces output again.
|
|
2841
2831
|
consecutiveEmptyTurns: this.emptyTurns.consecutiveEmptyTurns(),
|
|
2842
|
-
unreportedTokenUsage: this.tokenUsageCoverage.unreported,
|
|
2832
|
+
unreportedTokenUsage: this.tokenUsageCoverage.unreported,
|
|
2843
2833
|
activeWork,
|
|
2844
2834
|
activeSubagents,
|
|
2845
2835
|
activeSubagentCount: activeSubagents.length,
|
|
2846
2836
|
...(this.lastProviderEventAt !== undefined ? { lastProviderEventAt: this.lastProviderEventAt } : {}),
|
|
2847
2837
|
providerState,
|
|
2848
|
-
//
|
|
2838
|
+
// this is a one-shot marker. An explicit null lets Relay's merge-patch status
|
|
2849
2839
|
// ingress delete the previously consumed event on the next frame instead of auditing the
|
|
2850
2840
|
// same latched watchdog transition for every subsequent provider update.
|
|
2851
2841
|
timelineEvent: timelineEvent ?? null,
|
|
2852
2842
|
transport: this.bus.transportState,
|
|
2853
|
-
//
|
|
2843
|
+
// normalized liveness verdict; the server reads ONLY this for stuck-busy.
|
|
2854
2844
|
// The raw fields above stay for other consumers (timeline rendering, diagnostics).
|
|
2855
2845
|
liveness,
|
|
2856
2846
|
},
|
|
@@ -2860,8 +2850,6 @@ export class AgentRunner {
|
|
|
2860
2850
|
if (published && this.pendingTimelineEvent === timelineEvent) this.pendingTimelineEvent = undefined;
|
|
2861
2851
|
}
|
|
2862
2852
|
|
|
2863
|
-
private startLiveness(): void { this.liveness.start(); }
|
|
2864
|
-
|
|
2865
2853
|
private logRunnerDiagnostic(message: string): void { this.context.logRunnerDiagnostic(message); }
|
|
2866
2854
|
private sessionLog(message: string): void { this.context.sessionLog(message); }
|
|
2867
2855
|
private sessionDebug(message: string): void { this.context.sessionDebug(message); }
|
|
@@ -2873,14 +2861,13 @@ export class AgentRunner {
|
|
|
2873
2861
|
cwd: this.options.cwd,
|
|
2874
2862
|
fallbackBaseDir: orchestratorBaseDirFromEnv(),
|
|
2875
2863
|
});
|
|
2864
|
+
this.registrationMeta.scratchDir = this.scratch.tmpDir;
|
|
2876
2865
|
} catch (error) {
|
|
2877
2866
|
this.logRunnerDiagnostic(`session scratch setup failed: ${errMessage(error)}`);
|
|
2878
2867
|
}
|
|
2879
2868
|
}
|
|
2880
2869
|
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
// #1245 — a newly allowed tool group or scope reaches the next generation without a restart.
|
|
2870
|
+
// a newly allowed tool group or scope reaches the next generation without a restart.
|
|
2884
2871
|
private remintRuntimeTokenAtBoundary(reason: "clear" | "compact"): Promise<Record<string, unknown>> {
|
|
2885
2872
|
return this.credentials.remintRuntimeTokenAtBoundary(reason);
|
|
2886
2873
|
}
|
|
@@ -2890,7 +2877,7 @@ export class AgentRunner {
|
|
|
2890
2877
|
const probeMetrics = this.latestProbeMetrics();
|
|
2891
2878
|
const probeContext = probeMetrics ? contextStateFromProbeMetrics(probeMetrics) : undefined;
|
|
2892
2879
|
let context = processContext ?? probeContext;
|
|
2893
|
-
//
|
|
2880
|
+
// when a provider has no event-stream context of its own, fall back to
|
|
2894
2881
|
// the transcript so context.lastUpdatedAt keeps advancing after an in-process
|
|
2895
2882
|
// compact/clear/native-resume freezes the status-line probe. Freshest wins, so
|
|
2896
2883
|
// a live probe still takes precedence (source stays "statusline").
|
|
@@ -2900,7 +2887,7 @@ export class AgentRunner {
|
|
|
2900
2887
|
context = derived;
|
|
2901
2888
|
}
|
|
2902
2889
|
}
|
|
2903
|
-
//
|
|
2890
|
+
// test rig — override the reported context with the forced synthetic while the one-shot
|
|
2904
2891
|
// latch is armed. Applied AFTER the real context is resolved (so tokensMax/window carry over) and
|
|
2905
2892
|
// BEFORE providerCapabilities + meta.context are built from it, so the server ingests the forced
|
|
2906
2893
|
// utilization and its REAL lifecycle tick drives the REAL advisory + forced clear-inject.
|