agent-relay-runner 0.129.11 → 0.129.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
package/src/runner-core.ts
CHANGED
|
@@ -2,31 +2,34 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, ProviderState, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
5
|
-
import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
5
|
+
import { errMessage, RelayBusClient, RelayHttpClient, isCodexLeanWorkerProfile, CODEX_WORKER_ONDEMAND_TOOLS } from "agent-relay-sdk";
|
|
6
6
|
import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
|
|
7
7
|
import { isPidAlive } from "agent-relay-sdk/process-utils";
|
|
8
8
|
import { providerAttachmentText, providerMessageText } from "./adapter";
|
|
9
9
|
import { computeLivenessSignal, type LivenessInputs } from "./liveness";
|
|
10
|
-
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderStatusUpdate, ProviderUserPromptInput, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
10
|
+
import type { ManagedProcess, PaneObservation, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderStatusUpdate, ProviderUserPromptInput, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
11
11
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
12
12
|
import { ClaimTracker } from "./claim-tracker";
|
|
13
13
|
import { ProviderQuotaHarvest } from "./adapters/provider-quota-harvest";
|
|
14
14
|
import { readRunnerContextProbeState } from "./context-probe-state";
|
|
15
|
+
import { transcriptDerivedContext, type TranscriptContextCache } from "./transcript-context";
|
|
15
16
|
import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
|
|
16
17
|
import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
|
|
17
18
|
import { Outbox, type OutboxRecord } from "./outbox";
|
|
18
19
|
import { getManifest } from "agent-relay-providers";
|
|
19
20
|
import { profileUsesProviderHostGlobals } from "./profile-home";
|
|
20
21
|
import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
|
|
21
|
-
import { RelayMcpProxy } from "./relay-mcp-proxy";
|
|
22
|
+
import { RelayMcpProxy, isolatedWorkspaceDescriptor } from "./relay-mcp-proxy";
|
|
22
23
|
import { runtimeMetadata } from "./version";
|
|
23
24
|
import { logger, parseLogLevel } from "./logger";
|
|
24
25
|
import { ensureSessionScratch, reapSessionLauncher, reapSessionScratch, sweepStaleLaunchers, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
|
|
25
26
|
import { deliverBufferedMcpCall as deliverBufferedMcpOutboxCall, deliverRunnerOutboxEvent } from "./mcp-outbox";
|
|
26
27
|
import { RunnerInsights } from "./runner-insights";
|
|
27
28
|
import { BusyReconciler } from "./busy-reconciler";
|
|
29
|
+
import { reconciledTurnTimelineEvent } from "./turn-reconcile-event";
|
|
28
30
|
import { publishCapturedResponse } from "./response-capture-report";
|
|
29
31
|
import { PendingPermissionStore, type PendingPermissionRequestRecord } from "./pending-permission-store";
|
|
32
|
+
import { ProviderInputQueue, type ProviderInputTicket } from "./provider-input-queue";
|
|
30
33
|
import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } from "./relay-injection-events";
|
|
31
34
|
import { providerTerminalSession, providerTerminalSocket } from "./process-meta";
|
|
32
35
|
import { capsFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
|
|
@@ -41,7 +44,9 @@ import {
|
|
|
41
44
|
isContextState,
|
|
42
45
|
isHttpAuthError,
|
|
43
46
|
isHttpStatusError,
|
|
47
|
+
isTurnInProgressDeliveryError,
|
|
44
48
|
lifecycleCapabilities,
|
|
49
|
+
PROVIDER_INPUT_COMMAND_TYPES,
|
|
45
50
|
providerExitLastError,
|
|
46
51
|
providerExitDiagnostics,
|
|
47
52
|
providerExitResumeId,
|
|
@@ -183,6 +188,14 @@ export class AgentRunner {
|
|
|
183
188
|
// delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
|
|
184
189
|
private readonly sessionOutbox: Outbox;
|
|
185
190
|
private readonly pendingPermissionStore: PendingPermissionStore;
|
|
191
|
+
// #1565 F1 — the one gate in front of the provider's single input channel. Every payload the
|
|
192
|
+
// runner delivers (message batch, injected prompt, self-resume continuation, memory preamble)
|
|
193
|
+
// passes through it, so two payloads for one agent can never interleave at the wire. FIFO, with
|
|
194
|
+
// the place in line taken synchronously at dispatch, so the payload the relay created first is
|
|
195
|
+
// the one delivered first.
|
|
196
|
+
private readonly providerInput = new ProviderInputQueue({
|
|
197
|
+
onStuck: (heldMs) => logger.warn("delivery", `provider input stream held for ${heldMs}ms; force-releasing so delivery cannot wedge`),
|
|
198
|
+
});
|
|
186
199
|
private sessionBatchSeq = 0;
|
|
187
200
|
private readonly insights: RunnerInsights;
|
|
188
201
|
private readonly busyReconciler: BusyReconciler;
|
|
@@ -232,6 +245,14 @@ export class AgentRunner {
|
|
|
232
245
|
// Last transcript path seen this session — used by end-of-session Insights (#184)
|
|
233
246
|
// when the SessionEnd hook payload omits it.
|
|
234
247
|
private lastTranscriptPath?: string;
|
|
248
|
+
// #1512: mtime-keyed cache of the last transcript-derived context usage, so the
|
|
249
|
+
// heartbeat's fallback parse doesn't re-read a large transcript that hasn't changed.
|
|
250
|
+
private transcriptContextCache?: TranscriptContextCache;
|
|
251
|
+
// #1235 — true while a monitorless (hookless) provider turn is being mirrored natively:
|
|
252
|
+
// the adapter supplied a transcript path on the provider-turn busy edge, so the runner
|
|
253
|
+
// drives the reasoning tail itself (start on busy, final-capture on idle) instead of via
|
|
254
|
+
// Stop/UserPromptSubmit hooks the lean/isolated worker never installs.
|
|
255
|
+
private nativeSessionCaptureActive = false;
|
|
235
256
|
private lifecycleAction?: LifecycleAction;
|
|
236
257
|
// Coalesces concurrent pre-session-destroy runs (e.g. the shutdown bus command and the
|
|
237
258
|
// SessionEnd hook both fire for the same teardown) so the cursor isn't raced.
|
|
@@ -423,8 +444,45 @@ export class AgentRunner {
|
|
|
423
444
|
}
|
|
424
445
|
|
|
425
446
|
private registerProviderSessionRoutes(): void {
|
|
426
|
-
|
|
427
|
-
|
|
447
|
+
// (#1617) — neither publisher has a try/catch of its own, and both bottom out in
|
|
448
|
+
// sessionOutbox.enqueue: a synchronous bun:sqlite INSERT that throws outright once the outbox
|
|
449
|
+
// is closed (teardown ordering in stop(): the adapter keeps emitting after close) or the
|
|
450
|
+
// database errors. This is the highest-frequency fire-and-forget site in the runner — every
|
|
451
|
+
// provider session event passes through it — so leaving it unguarded is the likeliest way a
|
|
452
|
+
// live agent silently stops existing (Bun exits(1) on an unhandled rejection, #1614).
|
|
453
|
+
this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event).catch((error) => this.reportBackgroundRejection("provider session event publish", "session.publish_failed", "Provider session event not mirrored", error)); });
|
|
454
|
+
this.options.adapter.onSessionEvents?.((events) => { void this.publishProviderSessionEvents(events).catch((error) => this.reportBackgroundRejection("provider session event batch publish", "session.publish_failed", "Provider session events not mirrored", error)); });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The adapter's status seam, split out of run() (#1617) so the guarded restart call below is
|
|
458
|
+
// reachable without booting a whole runner — the crash it guards against is exactly the one a
|
|
459
|
+
// test has to be able to drive.
|
|
460
|
+
/**
|
|
461
|
+
* #1651 — the terminus of the pane-diagnostic channel. It writes to the session log and
|
|
462
|
+
* stops. This method exists to be boring: it is the proof that a pane reading has somewhere
|
|
463
|
+
* to go OTHER than setProviderStatus, which is what let the five prompt gates and the
|
|
464
|
+
* connection-retry detector be stripped of their authority without going silent. If a future
|
|
465
|
+
* change makes this mutate anything, it has re-created the defect class #1651 closed.
|
|
466
|
+
*/
|
|
467
|
+
private recordPaneObservation(observation: PaneObservation): void {
|
|
468
|
+
const detail = observation.message ? `: ${observation.message}` : "";
|
|
469
|
+
this.sessionLog(`pane observation (advisory, no state change) ${observation.eventType}/${observation.detectorId}${detail}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private handleAdapterStatusChange(status: ProviderStatusUpdate): void {
|
|
473
|
+
if (this.restartInProgress || this.restartPending) return;
|
|
474
|
+
const semanticStatus = typeof status === "string" ? status : status.status;
|
|
475
|
+
if (this.shouldRestartUnexpectedProviderExit(semanticStatus)) {
|
|
476
|
+
// (#1617) — restartUnexpectedProviderExit is an outer try/finally with NO catch wrapped
|
|
477
|
+
// around awaited work that can still throw (recoverNativeSelfResumeExit, the undetermined-
|
|
478
|
+
// exit confirmation read, the timeline/status publishes). A reject here used to kill the
|
|
479
|
+
// runner outright: the crash path for the exact situation this method exists to recover
|
|
480
|
+
// from, on a runner nothing respawns (#1618).
|
|
481
|
+
void this.restartUnexpectedProviderExit(semanticStatus).catch((error) => this.reportBackgroundRejection("unexpected provider exit restart", "provider.restart_failed", "Provider restart after unexpected exit failed", error));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.setProviderStatus(status);
|
|
485
|
+
if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
|
|
428
486
|
}
|
|
429
487
|
|
|
430
488
|
async run(): Promise<void> {
|
|
@@ -449,16 +507,8 @@ export class AgentRunner {
|
|
|
449
507
|
});
|
|
450
508
|
this.startMcpProxy();
|
|
451
509
|
this.writeRunnerInfoFile();
|
|
452
|
-
this.options.adapter.onStatusChange((status) =>
|
|
453
|
-
|
|
454
|
-
const semanticStatus = typeof status === "string" ? status : status.status;
|
|
455
|
-
if (this.shouldRestartUnexpectedProviderExit(semanticStatus)) {
|
|
456
|
-
void this.restartUnexpectedProviderExit(semanticStatus);
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
this.setProviderStatus(status);
|
|
460
|
-
if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
|
|
461
|
-
});
|
|
510
|
+
this.options.adapter.onStatusChange((status) => this.handleAdapterStatusChange(status));
|
|
511
|
+
this.options.adapter.onPaneObservation?.((observation) => this.recordPaneObservation(observation));
|
|
462
512
|
this.registerProviderSessionRoutes();
|
|
463
513
|
this.bus.on("message.new", (message) => {
|
|
464
514
|
// A delivered message may create a new reply obligation — warm the snapshot so the
|
|
@@ -468,7 +518,7 @@ export class AgentRunner {
|
|
|
468
518
|
this.enqueueMessage(msg);
|
|
469
519
|
});
|
|
470
520
|
this.bus.on("command", (type, params, commandId, command) => {
|
|
471
|
-
|
|
521
|
+
this.dispatchCommand(type, params, commandId, command);
|
|
472
522
|
});
|
|
473
523
|
this.bus.on("error", (code, message) => this.handleBusError(String(code), String(message)));
|
|
474
524
|
await registerWithinDeadline(this.bus, registrationTimeoutMsFromEnv(), this.options.relayUrl, (reason) => logger.fatal("register", reason));
|
|
@@ -516,6 +566,7 @@ export class AgentRunner {
|
|
|
516
566
|
this.disarmBusyLivenessReconcile();
|
|
517
567
|
this.busyReconciler.disarm();
|
|
518
568
|
this.stopReasoningTail();
|
|
569
|
+
this.nativeSessionCaptureActive = false;
|
|
519
570
|
this.obligationCache.stop();
|
|
520
571
|
this.outbox.close(); this.sessionOutbox.close(); this.pendingPermissionStore.close();
|
|
521
572
|
this.proxy?.stop();
|
|
@@ -527,9 +578,24 @@ export class AgentRunner {
|
|
|
527
578
|
// relay with the runner's LIVE token, buffers bufferable writes durably during a relay outage,
|
|
528
579
|
// and narrows the tool list to this agent's workspace context. Best-effort: if it can't bind,
|
|
529
580
|
// we fall back to a direct relay MCP connection (the agent env still works, no resilience).
|
|
581
|
+
// #1450 — the on-demand relay set to hide from a lean Codex worker's DIRECT relay client (so only
|
|
582
|
+
// the eager hot-path set boots), or undefined for any agent that keeps its full relay surface. The
|
|
583
|
+
// gate MIRRORS the callmux relay-downstream gate (launch-assembly) EXACTLY — worker profile, no
|
|
584
|
+
// client-native tool deferral, sharedMcp on (the metaOnly callmux that fronts these exists), relay
|
|
585
|
+
// mcp on — so the direct client is narrowed only when callmux is simultaneously fronting the set.
|
|
586
|
+
// ("proxy active" is implicit: only a constructed proxy consults this, and the relay downstream is
|
|
587
|
+
// added only when the proxy is active — the two are on/off together, never a half state.)
|
|
588
|
+
private codexWorkerOnDemandTools(): Set<string> | undefined {
|
|
589
|
+
const profile = this.options.agentProfile;
|
|
590
|
+
if (!isCodexLeanWorkerProfile(profile?.name) || !profile?.sharedMcp || profile?.relay?.mcp === false) return undefined;
|
|
591
|
+
if (getManifest(this.options.provider)?.mcpToolManifest?.clientNativeToolDeferral !== false) return undefined;
|
|
592
|
+
return new Set(CODEX_WORKER_ONDEMAND_TOOLS);
|
|
593
|
+
}
|
|
594
|
+
|
|
530
595
|
private startMcpProxy(): void {
|
|
531
596
|
if (!this.mcpProxyEnabled) return;
|
|
532
597
|
try {
|
|
598
|
+
const onDemandTools = this.codexWorkerOnDemandTools();
|
|
533
599
|
this.proxy = new RelayMcpProxy({
|
|
534
600
|
relayMcpEndpoint: relayMcpEndpoint(this.options.relayUrl),
|
|
535
601
|
getToken: () => this.currentToken,
|
|
@@ -541,7 +607,8 @@ export class AgentRunner {
|
|
|
541
607
|
idempotencyKey: call.idempotencyKey,
|
|
542
608
|
});
|
|
543
609
|
},
|
|
544
|
-
initialContext: { isolatedWorktree: this.ownsIsolatedWorktree() },
|
|
610
|
+
initialContext: { isolatedWorktree: this.ownsIsolatedWorktree(), workspaceDescriptor: isolatedWorkspaceDescriptor(this.options.workspace, this.ownsIsolatedWorktree()) },
|
|
611
|
+
...(onDemandTools ? { onDemandTools } : {}),
|
|
545
612
|
});
|
|
546
613
|
this.mcpProxyEndpoint = this.proxy.start().url;
|
|
547
614
|
logger.info("mcp-proxy", `runner MCP proxy listening at ${this.mcpProxyEndpoint} (worktree=${this.ownsIsolatedWorktree()})`);
|
|
@@ -617,6 +684,10 @@ export class AgentRunner {
|
|
|
617
684
|
// Stage 2 (#215): the MCP endpoint the agent's client should target — the runner-local
|
|
618
685
|
// proxy when active, undefined when disabled (adapters fall back to the direct relay URL).
|
|
619
686
|
...(this.mcpProxyEndpoint ? { relayMcpEndpoint: this.mcpProxyEndpoint } : {}),
|
|
687
|
+
// #1450: the proxy secret, exposed to the assembler ONLY when the proxy is active, so the
|
|
688
|
+
// Codex per-agent metaOnly callmux can reach the relay endpoint on-demand THROUGH the proxy
|
|
689
|
+
// (live-token-injected — never the relay token on disk, #215).
|
|
690
|
+
...(this.mcpProxyEndpoint ? { mcpProxySecret: this.mcpProxySecret } : {}),
|
|
620
691
|
// #672: the shared host-listener URL the `callmux bridge` connects to (env → default).
|
|
621
692
|
// Always resolved; the assembler only emits the bridge when `agentProfile.sharedMcp` is on.
|
|
622
693
|
sharedMcpUrl: resolveSharedMcpUrl(),
|
|
@@ -689,7 +760,7 @@ export class AgentRunner {
|
|
|
689
760
|
this.deliveringInitialPrompt = true;
|
|
690
761
|
const attempt = (this.initialPromptAttempts += 1);
|
|
691
762
|
try {
|
|
692
|
-
this.recordInjectedPrompt(prompt); await this.options.adapter.deliverInitialPrompt(this.process
|
|
763
|
+
this.recordInjectedPrompt(prompt); await this.providerInput.run(() => this.options.adapter.deliverInitialPrompt!(this.process!, prompt, { readyTimeoutMs, isInitial: true }));
|
|
693
764
|
this.pendingInitialPrompt = undefined;
|
|
694
765
|
} catch (error) {
|
|
695
766
|
const giveUp = attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
|
|
@@ -754,7 +825,9 @@ export class AgentRunner {
|
|
|
754
825
|
agentId: this.agentId,
|
|
755
826
|
onError: (message) => logger.error("runner", message),
|
|
756
827
|
});
|
|
757
|
-
|
|
828
|
+
// #1565 F1 — behind the same gate as injected payloads: a message batch and a boundary
|
|
829
|
+
// inject arriving together must not paste into each other.
|
|
830
|
+
await this.providerInput.run(() => this.options.adapter.deliver(this.process!, prepared));
|
|
758
831
|
for (const message of deliverable) {
|
|
759
832
|
await this.http.recordMessageDeliveryAttempt(message.id, { agentId: this.agentId, status: "delivered" }).catch(() => {});
|
|
760
833
|
await this.http.markRead(message.id, this.agentId).catch(() => {});
|
|
@@ -774,7 +847,24 @@ export class AgentRunner {
|
|
|
774
847
|
}
|
|
775
848
|
}
|
|
776
849
|
|
|
777
|
-
|
|
850
|
+
/**
|
|
851
|
+
* The bus dispatch seam (#1565 F1).
|
|
852
|
+
*
|
|
853
|
+
* Commands that put a payload into the provider's input channel take their place in the
|
|
854
|
+
* `providerInput` queue HERE, synchronously, before the first `await` — so the order the relay
|
|
855
|
+
* created them in (continuation first, memory beside it) is the order they are delivered in, no
|
|
856
|
+
* matter how long each command's own bookkeeping takes. Everything else keeps dispatching
|
|
857
|
+
* fire-and-forget: the gate is on the wire, not on command handling.
|
|
858
|
+
*
|
|
859
|
+
* The ticket is released when the command settles even if delivery never happened (a throw before
|
|
860
|
+
* the deliver, an unsupported provider), so a failed command can never wedge the stream.
|
|
861
|
+
*/
|
|
862
|
+
private dispatchCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): void {
|
|
863
|
+
const ticket = PROVIDER_INPUT_COMMAND_TYPES.has(type) ? this.providerInput.reserve() : undefined;
|
|
864
|
+
void this.handleCommand(type, params, commandId, command, ticket).finally(() => ticket?.release());
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>, ticket?: ProviderInputTicket): Promise<void> {
|
|
778
868
|
const target = typeof command?.target === "string" ? command.target : this.agentId;
|
|
779
869
|
if (target !== this.agentId && target !== this.options.runnerId) return;
|
|
780
870
|
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "agent.interrupt" && type !== "prompt.inject") return;
|
|
@@ -814,6 +904,8 @@ export class AgentRunner {
|
|
|
814
904
|
} else if (type === "agent.clearContext") {
|
|
815
905
|
if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
|
|
816
906
|
providerResult = await this.options.adapter.clearContext(this.process);
|
|
907
|
+
const coupled = await this.deliverCoupledContinuationAfterClear(params, providerResult || undefined, ticket);
|
|
908
|
+
if (coupled) providerResult = { ...(providerResult ?? {}), ...coupled };
|
|
817
909
|
} else if (type === "agent.interrupt") {
|
|
818
910
|
if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
|
|
819
911
|
const redirect = params.redirect && typeof params.redirect === "object" && !Array.isArray(params.redirect)
|
|
@@ -829,11 +921,11 @@ export class AgentRunner {
|
|
|
829
921
|
if (redirect) providerResult = { interrupt: providerResult, redirect: await this.injectPrompt(redirect) };
|
|
830
922
|
} else if (type === "agent.injectContext") {
|
|
831
923
|
if (!this.process) throw new Error("provider process is unavailable");
|
|
832
|
-
providerResult = await this.injectContext(params);
|
|
924
|
+
providerResult = await this.injectContext(params, ticket);
|
|
833
925
|
} else if (type === "agent.permissionDecision") {
|
|
834
926
|
providerResult = await this.respondToPermissionDecision(params);
|
|
835
927
|
} else if (type === "prompt.inject") {
|
|
836
|
-
providerResult = await this.injectPrompt(params);
|
|
928
|
+
providerResult = await this.injectPrompt(params, ticket);
|
|
837
929
|
} else await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
|
|
838
930
|
await this.updateCommand(commandId, "succeeded", {
|
|
839
931
|
action: type,
|
|
@@ -868,7 +960,54 @@ export class AgentRunner {
|
|
|
868
960
|
}
|
|
869
961
|
}
|
|
870
962
|
|
|
871
|
-
|
|
963
|
+
// #1588 — couple the self-resume continuation delivery into the SAME clearContext command that
|
|
964
|
+
// wipes the context, so the two can never race across separate commands/transports again. The
|
|
965
|
+
// relay embeds the fully-built (identity-augmented) continuation in the clear command's
|
|
966
|
+
// `selfResume.continuation`; we deliver it in-process, strictly AFTER adapter.clearContext has
|
|
967
|
+
// returned. `continuationDelivered` tells the relay whether it can skip the redundant reactive
|
|
968
|
+
// inject or must still fire its #1023 backstop — and it MUST be truthful, because the relay skips
|
|
969
|
+
// BOTH backstops on continuationDelivered===true, so a false-positive true is a silent-wipe risk
|
|
970
|
+
// with no recovery. So `true` requires TWO things: (1) the adapter's clear-aware barrier
|
|
971
|
+
// POSITIVELY confirmed the TUI clear committed (clearResult.clearConfirmed === true — the strong
|
|
972
|
+
// clear-committed evidence), and (2) the in-process deliver resolved. A settle timeout / uncertain
|
|
973
|
+
// clear → clearConfirmed !== true → we still deliver in-process (best effort) but report false so
|
|
974
|
+
// the backstop re-injects. Uncertainty ALWAYS resolves to false (backstop covers), never true.
|
|
975
|
+
private async deliverCoupledContinuationAfterClear(params: Record<string, unknown>, clearResult: Record<string, unknown> | undefined, ticket?: ProviderInputTicket): Promise<Record<string, unknown> | undefined> {
|
|
976
|
+
// Opt-in per provider: only adapters whose clearContext is a fire-and-forget keystroke with no
|
|
977
|
+
// completion barrier (Claude) couple the continuation in-process. Codex's clearContext is an
|
|
978
|
+
// awaited atomic threadStart (the await IS the barrier), so it keeps the separate confirmed-inject
|
|
979
|
+
// flow untouched — never sets continuationDelivered, so the relay never skips its reactive inject.
|
|
980
|
+
if (!this.options.adapter.couplesClearInjectContinuation) return undefined;
|
|
981
|
+
const selfResume = params.selfResume !== null && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
|
|
982
|
+
? params.selfResume as Record<string, unknown>
|
|
983
|
+
: undefined;
|
|
984
|
+
if (!selfResume || selfResume.mode !== "clear-inject") return undefined;
|
|
985
|
+
const continuation = typeof selfResume.continuation === "string" ? selfResume.continuation.trim() : "";
|
|
986
|
+
if (!continuation) return undefined;
|
|
987
|
+
// Only a genuine clear-commit confirmation from the adapter's clear-aware barrier permits a
|
|
988
|
+
// truthful continuationDelivered:true. Absent/false (older runner, or a settle timeout) → false.
|
|
989
|
+
const clearConfirmed = clearResult?.clearConfirmed === true;
|
|
990
|
+
try {
|
|
991
|
+
// #1607 N3 — reuse the outer clearContext ticket instead of letting injectContext reserve a
|
|
992
|
+
// fresh one, which would queue behind (and thus deadlock on) the still-held outer ticket.
|
|
993
|
+
await this.injectContext({ content: continuation, reason: "self-resume-continuation", selfResume }, ticket);
|
|
994
|
+
if (!clearConfirmed) {
|
|
995
|
+
// Deliver ran, but the clear was NOT positively confirmed committed — the continuation may
|
|
996
|
+
// have raced a still-processing clear. Report false so the relay backstop re-injects out of
|
|
997
|
+
// band. A redundant re-inject is always preferable to a silent wipe.
|
|
998
|
+
this.sessionLog("coupled self-resume continuation delivered but clear commit was NOT confirmed; relay backstop will re-inject");
|
|
999
|
+
}
|
|
1000
|
+
return { continuationDelivered: clearConfirmed };
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
// The clear already ran — reporting the command as failed would strand the agent with the
|
|
1003
|
+
// relay unaware its context is gone. Instead report the clear succeeded WITHOUT
|
|
1004
|
+
// continuationDelivered so the relay's reactive/sweep backstop re-injects out-of-band.
|
|
1005
|
+
this.sessionLog(`coupled self-resume continuation delivery failed, relay backstop will retry: ${errMessage(error)}`);
|
|
1006
|
+
return { continuationDelivered: false };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private async injectContext(params: Record<string, unknown>, ticket?: ProviderInputTicket): Promise<Record<string, unknown>> {
|
|
872
1011
|
const content = typeof params.content === "string" ? params.content.trim() : "";
|
|
873
1012
|
if (!content) throw new Error("content required");
|
|
874
1013
|
const memoryIds = Array.isArray(params.memoryIds) ? params.memoryIds.filter((id): id is string => typeof id === "string") : [];
|
|
@@ -877,7 +1016,7 @@ export class AgentRunner {
|
|
|
877
1016
|
? params.selfResume as Record<string, unknown>
|
|
878
1017
|
: undefined;
|
|
879
1018
|
const isSelfResumeContinuation = reason === "self-resume-continuation" || Boolean(selfResume);
|
|
880
|
-
|
|
1019
|
+
const payload: Message = {
|
|
881
1020
|
id: 0,
|
|
882
1021
|
from: "system",
|
|
883
1022
|
to: this.agentId,
|
|
@@ -893,7 +1032,24 @@ export class AgentRunner {
|
|
|
893
1032
|
},
|
|
894
1033
|
readBy: [],
|
|
895
1034
|
createdAt: Date.now(),
|
|
896
|
-
}
|
|
1035
|
+
};
|
|
1036
|
+
// #1565 F1 — one payload at a time, in dispatch order (see dispatchCommand). The ticket, when
|
|
1037
|
+
// present, is the place in line this command took the moment it arrived.
|
|
1038
|
+
const deliver = (slot?: ProviderInputTicket) => this.providerInput.run(() => this.options.adapter.deliver(this.process!, [payload]), slot);
|
|
1039
|
+
try {
|
|
1040
|
+
await deliver(ticket);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
// Codex has no input buffer: a payload offered during a live turn is rejected rather than
|
|
1043
|
+
// queued (#1339). Serializing the boundary pair means the SECOND payload now meets the turn
|
|
1044
|
+
// the first one started, so re-offer it once the turn ends instead of failing the command —
|
|
1045
|
+
// the same queue-behind-the-turn semantics Claude's transports get for free from the CLI's
|
|
1046
|
+
// own buffer. A timeout waiting for the turn re-throws: the command fails, the memory
|
|
1047
|
+
// delta-gate does not advance, and the relay backstop re-sends. Never a silent drop.
|
|
1048
|
+
if (!isTurnInProgressDeliveryError(error)) throw error;
|
|
1049
|
+
this.sessionLog(`context injection (${reason ?? "no reason"}) deferred behind the active provider turn; re-delivering when it ends`);
|
|
1050
|
+
await this.busyReconciler.waitForProviderTurnClear();
|
|
1051
|
+
await deliver();
|
|
1052
|
+
}
|
|
897
1053
|
return { memoryIds, injectedMemoryCount: memoryIds.length };
|
|
898
1054
|
}
|
|
899
1055
|
|
|
@@ -928,7 +1084,7 @@ export class AgentRunner {
|
|
|
928
1084
|
return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
|
|
929
1085
|
}
|
|
930
1086
|
|
|
931
|
-
private async injectPrompt(params: Record<string, unknown
|
|
1087
|
+
private async injectPrompt(params: Record<string, unknown>, ticket?: ProviderInputTicket): Promise<Record<string, unknown>> {
|
|
932
1088
|
const body = typeof params.body === "string" ? params.body : "";
|
|
933
1089
|
if (!body) throw new Error("body required");
|
|
934
1090
|
if (!this.process) throw new Error("provider process is unavailable");
|
|
@@ -944,7 +1100,7 @@ export class AgentRunner {
|
|
|
944
1100
|
this.recordInjectedPrompt(prompt.trim());
|
|
945
1101
|
// #1228: prompt injection is a manual dashboard "continue"/chat-box send, not the
|
|
946
1102
|
// agent's genuine first turn — never label it as the initial prompt.
|
|
947
|
-
await this.options.adapter.deliverInitialPrompt(this.process
|
|
1103
|
+
await this.providerInput.run(() => this.options.adapter.deliverInitialPrompt!(this.process!, prompt, { isInitial: false }), ticket);
|
|
948
1104
|
return { injected: true, messageId, attachmentCount: attachments.length };
|
|
949
1105
|
}
|
|
950
1106
|
|
|
@@ -967,7 +1123,7 @@ export class AgentRunner {
|
|
|
967
1123
|
}
|
|
968
1124
|
this.recordInjectedPrompt(text.trim());
|
|
969
1125
|
// #1228: this resolves a held AskUserQuestion mid-session — never the initial prompt.
|
|
970
|
-
await this.options.adapter.deliverInitialPrompt(this.process
|
|
1126
|
+
await this.providerInput.run(() => this.options.adapter.deliverInitialPrompt!(this.process!, text, { isInitial: false })).catch((error) => {
|
|
971
1127
|
this.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
|
|
972
1128
|
});
|
|
973
1129
|
}
|
|
@@ -1280,6 +1436,30 @@ export class AgentRunner {
|
|
|
1280
1436
|
this.publishStatus();
|
|
1281
1437
|
}
|
|
1282
1438
|
|
|
1439
|
+
// (#1617) — the reporting half of a guarded fire-and-forget call. Bun exits(1) on an unhandled
|
|
1440
|
+
// rejection and the runner installs no `unhandledRejection` handler, so any `void this.foo()`
|
|
1441
|
+
// whose target can reject takes the whole agent down — and nothing respawns it (#1618).
|
|
1442
|
+
//
|
|
1443
|
+
// Deliberately NOT a fire-and-forget wrapper: the `.catch` still has to be written out at every
|
|
1444
|
+
// call site, so adding one stays a visible decision rather than something a helper hides. All
|
|
1445
|
+
// this fixes is the SHAPE of the report — the same logger.error + publishRunnerTimelineEvent
|
|
1446
|
+
// pair attemptInitialPromptDelivery and the #1614 obligation re-injection already use, so a
|
|
1447
|
+
// background operation that dies is loud on the agent's own timeline instead of vanishing.
|
|
1448
|
+
private reportBackgroundRejection(scope: string, status: string, title: string, error: unknown): void {
|
|
1449
|
+
const body = errMessage(error);
|
|
1450
|
+
logger.error("runner", `${scope} failed: ${body}`);
|
|
1451
|
+
this.publishRunnerTimelineEvent({ status, timestamp: Date.now(), title, body });
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// (#1617) — renewRuntimeToken is an outer try/finally with no catch: the orchestrator re-mint
|
|
1455
|
+
// gate, publishStatus and scheduleRuntimeTokenRenewal all run unguarded after the inner
|
|
1456
|
+
// self-renew try. Both callers are timers/edges with nobody to await the promise, so a reject
|
|
1457
|
+
// reaches the top. Reported, never swallowed — a token that silently stops renewing strands the
|
|
1458
|
+
// agent off the bus, which is the exact failure this renewal loop exists to prevent.
|
|
1459
|
+
private renewRuntimeTokenInBackground(): void {
|
|
1460
|
+
void this.renewRuntimeToken().catch((error) => this.reportBackgroundRejection("runtime token renewal", "runtime-token-renewal-failed", "Runtime token renewal aborted", error));
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1283
1463
|
private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
|
|
1284
1464
|
await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
|
|
1285
1465
|
}
|
|
@@ -1408,20 +1588,49 @@ export class AgentRunner {
|
|
|
1408
1588
|
// answer/dismiss path already cleared this; this is the fallback for every other way
|
|
1409
1589
|
// forward progress can happen, so a stale question never lingers past its own turn.
|
|
1410
1590
|
this.pendingQuestionHold = undefined;
|
|
1591
|
+
// #1235 — a hookless (monitorless) adapter turn carries its live transcript path on the
|
|
1592
|
+
// busy edge. Drive the reasoning tail natively so lean/isolated Claude workers, which
|
|
1593
|
+
// never install the Stop/UserPromptSubmit hooks, still stream chat. Hook-driven agents
|
|
1594
|
+
// never set transcriptPath here, so their hook-triggered capture is untouched.
|
|
1595
|
+
const nativeTranscriptPath = typeof update !== "string" ? update.transcriptPath : undefined;
|
|
1596
|
+
if (nativeTranscriptPath) {
|
|
1597
|
+
this.lastTranscriptPath = nativeTranscriptPath;
|
|
1598
|
+
this.nativeSessionCaptureActive = true;
|
|
1599
|
+
void this.startNativeSessionCapture(nativeTranscriptPath);
|
|
1600
|
+
}
|
|
1411
1601
|
}
|
|
1412
1602
|
id = this.currentTurnId;
|
|
1413
1603
|
this.busyReconciler.arm();
|
|
1414
1604
|
} else if (status === "idle" && reason === "provider-turn") {
|
|
1415
1605
|
id = typeof update !== "string" && update.id ? update.id : this.currentTurnId ?? reason;
|
|
1416
1606
|
if (this.currentTurnId) { this.completedProviderTurns += 1; this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`); }
|
|
1607
|
+
// #1235 — for a native (hookless) turn, capture the final assistant response from the
|
|
1608
|
+
// transcript BEFORE tearing down the tail. Snapshot the ctx while currentTurnId is still
|
|
1609
|
+
// set so the closing response is tagged with the correct turn.
|
|
1610
|
+
const nativeFinish = this.nativeSessionCaptureActive;
|
|
1611
|
+
const nativeTranscriptPath = nativeFinish ? ((typeof update !== "string" ? update.transcriptPath : undefined) ?? this.lastTranscriptPath) : undefined;
|
|
1612
|
+
const nativeCtx = nativeFinish ? this.sessionTurnContext() : undefined;
|
|
1417
1613
|
this.currentTurnId = undefined;
|
|
1418
1614
|
this.currentTurnStartedAt = undefined;
|
|
1419
1615
|
this.compactionMidTurn = false;
|
|
1420
1616
|
this.busyReconciler.disarm();
|
|
1421
|
-
|
|
1617
|
+
if (nativeFinish) {
|
|
1618
|
+
this.nativeSessionCaptureActive = false;
|
|
1619
|
+
void this.finishNativeSessionCapture(nativeTranscriptPath, nativeCtx!);
|
|
1620
|
+
} else {
|
|
1621
|
+
this.stopReasoningTail();
|
|
1622
|
+
}
|
|
1422
1623
|
// Providers with append-system-prompt policy use lifecycle hooks for obligation handling;
|
|
1423
1624
|
// others need the runner to re-inject pending obligations on idle.
|
|
1424
|
-
|
|
1625
|
+
// (#1614) — this delivery can suspend on the FIFO (#1607 N1) and, once resumed, reject if
|
|
1626
|
+
// the process died out from under it or the adapter is mid-turn; reported the same way
|
|
1627
|
+
// attemptInitialPromptDelivery reports its own adapter.deliver failures, so it can never
|
|
1628
|
+
// reach the top as an unhandled rejection (Bun exits(1) on those) and never vanish silently.
|
|
1629
|
+
// (#1617) folded onto the shared reportBackgroundRejection shape — same channel, same
|
|
1630
|
+
// status/title/body, one less hand-rolled copy of the reporting block.
|
|
1631
|
+
if (getManifest(this.options.provider)?.launch?.managedPolicyPrompt?.alwaysOn !== "append-system-prompt") {
|
|
1632
|
+
void this.reInjectPendingObligation().catch((error) => this.reportBackgroundRejection("re-inject pending obligation", "obligation.reinject_failed", "Pending reply obligation not re-injected", error));
|
|
1633
|
+
}
|
|
1425
1634
|
}
|
|
1426
1635
|
// A genuine restart back to forward progress clears the terminal-exit marker so a later offline
|
|
1427
1636
|
// doesn't re-announce a stale provider exit.
|
|
@@ -1434,11 +1643,16 @@ export class AgentRunner {
|
|
|
1434
1643
|
// kill), the reconcile clears the stuck turn → idle. A dead pid means the turn is
|
|
1435
1644
|
// definitively over, so this can never falsely clear a live turn (#653's hazard).
|
|
1436
1645
|
const reportedPids = typeof update === "string" ? undefined : update.pids;
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1646
|
+
// #1138: process.pid is undefined for tmux-launched providers (Claude) — only
|
|
1647
|
+
// the adapter can resolve a real pid from the tmux session/socket. Without this,
|
|
1648
|
+
// an ordinary hook-driven turn tracks no pid at all, so the stale-turn backstop
|
|
1649
|
+
// below has nothing but elapsed time to go on.
|
|
1650
|
+
const fallbackPid = reason === "provider-turn"
|
|
1651
|
+
? (typeof this.process?.pid === "number" && this.process.pid > 0
|
|
1652
|
+
? this.process.pid
|
|
1653
|
+
: this.process ? this.options.adapter.resolveBusyPid?.(this.process) : undefined)
|
|
1654
|
+
: undefined;
|
|
1655
|
+
const livenessPids = reportedPids?.length ? reportedPids : (fallbackPid ? [fallbackPid] : undefined);
|
|
1442
1656
|
this.claims.startWork(reason, id, typeof update === "string" ? (livenessPids ? { pids: livenessPids } : {}) : {
|
|
1443
1657
|
label: update.label,
|
|
1444
1658
|
role: update.role,
|
|
@@ -1479,7 +1693,15 @@ export class AgentRunner {
|
|
|
1479
1693
|
this.rateLimitHold = hold;
|
|
1480
1694
|
if (fresh) this.publishRateLimitNotice(hold);
|
|
1481
1695
|
}
|
|
1482
|
-
|
|
1696
|
+
// #1146: native self-resume is invoked by the agent itself mid-turn (a tool call from
|
|
1697
|
+
// within its own active response), so the very next idle is almost always the CALLING
|
|
1698
|
+
// turn's own natural end — long before Claude Code even starts processing the queued
|
|
1699
|
+
// `/compact <continuation>`. Resolving on any idle here raced that unrelated end against
|
|
1700
|
+
// the real compaction and (de facto always) won, marking the command "succeeded" and
|
|
1701
|
+
// firing the continuation injection before compaction ever ran — a silent no-op. Gate on
|
|
1702
|
+
// `compactedAt` (stamped only by the genuine PostCompact "compacted" timeline event, via
|
|
1703
|
+
// noteCompacted) so this only resolves once compaction has actually happened.
|
|
1704
|
+
if (status === "idle" && this.nativeSelfResume.active?.compactedAt !== undefined) {
|
|
1483
1705
|
this.nativeSelfResume.resolve(this.nativeSelfResume.active, { resumed: true, via: "provider-idle" });
|
|
1484
1706
|
}
|
|
1485
1707
|
this.publishStatus();
|
|
@@ -1519,7 +1741,8 @@ export class AgentRunner {
|
|
|
1519
1741
|
// by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
|
|
1520
1742
|
// defense-in-depth for adapters that might echo only the unformatted body text.
|
|
1521
1743
|
this.recordInjectedPrompt(providerMessageText([replayMessage]));
|
|
1522
|
-
|
|
1744
|
+
// #1607 N1 — route through the FIFO like every other deliver/deliverInitialPrompt call site.
|
|
1745
|
+
await this.providerInput.run(() => this.options.adapter.deliver(this.process!, [replayMessage]));
|
|
1523
1746
|
// Kick off a cache refresh so a cleared obligation (agent replied) is detected
|
|
1524
1747
|
// before the next idle fires — without this, the stale snapshot re-injects up to
|
|
1525
1748
|
// MAX_REINJECTIONS times even after the agent has already /reply-ed.
|
|
@@ -1711,14 +1934,17 @@ export class AgentRunner {
|
|
|
1711
1934
|
this.sessionLog(`pre-destroy capture failed: ${errMessage(error)}`);
|
|
1712
1935
|
}
|
|
1713
1936
|
// For exit-bound transitions the runner won't be alive afterward to drain the durable
|
|
1714
|
-
//
|
|
1937
|
+
// outboxes, so block (bounded) on delivering what capture just enqueued. The session
|
|
1938
|
+
// outbox is separate from the Insights outbox: provider turn-final/report-up captures
|
|
1939
|
+
// live there, and closing it without a flush loses a transient worker's last report
|
|
1940
|
+
// after the reaper has already closed its work window (#1139). This runs before
|
|
1715
1941
|
// handleCommand's finally deletes the agent, so the runtime token is still valid here.
|
|
1716
1942
|
if (reasonExitsRunner(reason)) {
|
|
1717
1943
|
try {
|
|
1718
|
-
const delivered = await this.outbox.flush(OUTBOX_FLUSH_TIMEOUT_MS);
|
|
1719
|
-
if (!delivered) this.sessionLog(`
|
|
1944
|
+
const [delivered, sessionDelivered] = await Promise.all([this.outbox.flush(OUTBOX_FLUSH_TIMEOUT_MS), this.sessionOutbox.flush(OUTBOX_FLUSH_TIMEOUT_MS)]);
|
|
1945
|
+
if (!delivered || !sessionDelivered) this.sessionLog(`teardown outbox flush incomplete before ${reason}`);
|
|
1720
1946
|
} catch (error) {
|
|
1721
|
-
this.sessionLog(`
|
|
1947
|
+
this.sessionLog(`teardown outbox flush failed: ${errMessage(error)}`);
|
|
1722
1948
|
}
|
|
1723
1949
|
}
|
|
1724
1950
|
})();
|
|
@@ -1832,6 +2058,7 @@ export class AgentRunner {
|
|
|
1832
2058
|
origin: event.origin ?? "provider",
|
|
1833
2059
|
...(turnId ? { turnId } : {}),
|
|
1834
2060
|
...(event.label ? { label: event.label } : {}),
|
|
2061
|
+
...(event.category ? { toolCategory: event.category } : {}),
|
|
1835
2062
|
...(event.status ? { status: event.status } : {}),
|
|
1836
2063
|
...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
|
|
1837
2064
|
...(event.stepId ? { stepId: event.stepId } : {}),
|
|
@@ -1868,13 +2095,17 @@ export class AgentRunner {
|
|
|
1868
2095
|
// deliberately leaves the reasoning tail alone so a late clear can't truncate
|
|
1869
2096
|
// a turn's activity stream.
|
|
1870
2097
|
private forceClearProviderTurn(reason: string): void {
|
|
1871
|
-
|
|
2098
|
+
const work = this.claims.activeWork().find((w) => w.kind === "provider-turn");
|
|
2099
|
+
if (!work) return;
|
|
1872
2100
|
this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
|
|
2101
|
+
const event = reconciledTurnTimelineEvent({ turnId: this.currentTurnId ?? work.id, reason, startedAt: work.startedAt });
|
|
1873
2102
|
this.claims.clearWorkKind("provider-turn");
|
|
1874
2103
|
this.busyReconciler.providerTurnCleared();
|
|
1875
2104
|
this.currentTurnId = undefined;
|
|
1876
2105
|
this.compactionMidTurn = false;
|
|
1877
|
-
|
|
2106
|
+
// #1621: the clear is EMITTED, not silently applied — publishRunnerTimelineEvent publishes
|
|
2107
|
+
// the status too, so the idle edge and its explanation travel in the same frame.
|
|
2108
|
+
this.publishRunnerTimelineEvent(event);
|
|
1878
2109
|
}
|
|
1879
2110
|
|
|
1880
2111
|
// #650: reconcile pid-bearing busy work against actual process liveness. A tracked
|
|
@@ -1920,6 +2151,33 @@ export class AgentRunner {
|
|
|
1920
2151
|
this.options.adapter.stopSessionTrace?.();
|
|
1921
2152
|
}
|
|
1922
2153
|
|
|
2154
|
+
// #1235 — start the transcript reasoning tail for a hookless (monitorless) turn. Reuses the
|
|
2155
|
+
// exact adapter capture path the UserPromptSubmit hook drives for hooked agents; only the
|
|
2156
|
+
// trigger differs (native turn-start vs hook). A missing/old transcript makes the tail a
|
|
2157
|
+
// graceful no-op (poll finds nothing), so this can never crash a turn.
|
|
2158
|
+
private async startNativeSessionCapture(transcriptPath: string): Promise<void> {
|
|
2159
|
+
try {
|
|
2160
|
+
await this.options.adapter.handleUserPrompt?.({ prompt: "", transcriptPath }, this.sessionTurnContext());
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
this.sessionLog(`native session capture start failed: ${errMessage(error)}`);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// #1235 — capture the final assistant response for a hookless turn (the Stop hook's job for
|
|
2167
|
+
// hooked agents), then tear down the tail. captureSessionTurn drains the tail, reads the
|
|
2168
|
+
// closing turn text from the transcript, and emits the final `response` session event.
|
|
2169
|
+
private async finishNativeSessionCapture(transcriptPath: string | undefined, ctx: ProviderSessionTurnContext): Promise<void> {
|
|
2170
|
+
try {
|
|
2171
|
+
if (transcriptPath) {
|
|
2172
|
+
await this.options.adapter.captureSessionTurn?.({ transcriptPath, ...(ctx.turnId ? { promptId: ctx.turnId } : {}) }, ctx);
|
|
2173
|
+
}
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
this.sessionLog(`native session capture finish failed: ${errMessage(error)}`);
|
|
2176
|
+
} finally {
|
|
2177
|
+
this.stopReasoningTail();
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1923
2181
|
// Mirror a discreet, durable "context compacted" marker into chat via the existing
|
|
1924
2182
|
// session-mirror lane (not a parallel channel). `notice` renders as an inline timeline
|
|
1925
2183
|
// marker (never a bubble) and survives reload, unlike the ephemeral timeline-status one.
|
|
@@ -1966,7 +2224,7 @@ export class AgentRunner {
|
|
|
1966
2224
|
|
|
1967
2225
|
private isStaleProviderTurnOnlyBusy(
|
|
1968
2226
|
status: SemanticStatus,
|
|
1969
|
-
activeWork: Array<{ kind: string }>,
|
|
2227
|
+
activeWork: Array<{ kind: string; pids?: number[] }>,
|
|
1970
2228
|
busyReasons: string[],
|
|
1971
2229
|
liveness: { hasLiveWork: boolean; lastProgressAt: number },
|
|
1972
2230
|
): boolean {
|
|
@@ -1974,6 +2232,13 @@ export class AgentRunner {
|
|
|
1974
2232
|
if (activeWork.length === 0 || activeWork.some((work) => work.kind !== "provider-turn")) return false;
|
|
1975
2233
|
if (busyReasons.some((reason) => reason !== "provider-turn")) return false;
|
|
1976
2234
|
if (liveness.hasLiveWork || !liveness.lastProgressAt) return false;
|
|
2235
|
+
// #1138: a turn whose tracked pid is still alive is definitively still running — a
|
|
2236
|
+
// silent single Claude turn (no subagents, no compaction event) must not be
|
|
2237
|
+
// force-healed to idle purely because it's been quiet past the threshold. Only fall
|
|
2238
|
+
// back to the elapsed-time heuristic when there's no pid evidence to check; the #650
|
|
2239
|
+
// reconcile independently clears a confirmed-dead pid's work the instant it dies, so
|
|
2240
|
+
// this can never mask a genuine leak.
|
|
2241
|
+
if (activeWork.some((work) => work.pids?.some((pid) => isPidAlive(pid)))) return false;
|
|
1977
2242
|
return Date.now() - liveness.lastProgressAt >= STALE_PROVIDER_TURN_BUSY_MS;
|
|
1978
2243
|
}
|
|
1979
2244
|
|
|
@@ -2108,7 +2373,7 @@ export class AgentRunner {
|
|
|
2108
2373
|
if (this.reactiveTokenRecoveryAt && now - this.reactiveTokenRecoveryAt < REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS) return;
|
|
2109
2374
|
this.reactiveTokenRecoveryAt = now;
|
|
2110
2375
|
this.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token`);
|
|
2111
|
-
|
|
2376
|
+
this.renewRuntimeTokenInBackground();
|
|
2112
2377
|
}
|
|
2113
2378
|
|
|
2114
2379
|
private logHttpLivenessFailure(error: unknown, authFailed: boolean): void {
|
|
@@ -2195,7 +2460,7 @@ export class AgentRunner {
|
|
|
2195
2460
|
this.scheduleRuntimeTokenRenewal();
|
|
2196
2461
|
return;
|
|
2197
2462
|
}
|
|
2198
|
-
|
|
2463
|
+
this.renewRuntimeTokenInBackground();
|
|
2199
2464
|
}, schedule.delayMs);
|
|
2200
2465
|
}
|
|
2201
2466
|
|
|
@@ -2325,7 +2590,18 @@ export class AgentRunner {
|
|
|
2325
2590
|
const processContext = this.latestProcessContext();
|
|
2326
2591
|
const probeMetrics = this.latestProbeMetrics();
|
|
2327
2592
|
const probeContext = probeMetrics ? contextStateFromProbeMetrics(probeMetrics) : undefined;
|
|
2328
|
-
|
|
2593
|
+
let context = processContext ?? probeContext;
|
|
2594
|
+
// #1512: when a provider has no event-stream context of its own, fall back to
|
|
2595
|
+
// the transcript so context.lastUpdatedAt keeps advancing after an in-process
|
|
2596
|
+
// compact/clear/native-resume freezes the status-line probe. Freshest wins, so
|
|
2597
|
+
// a live probe still takes precedence (source stays "statusline").
|
|
2598
|
+
if (!processContext) {
|
|
2599
|
+
const derived = transcriptDerivedContext({ transcriptPath: this.lastTranscriptPath, derive: this.options.adapter.contextUsageFromTranscript, tokensMax: probeMetrics?.tokensMax, cache: this.transcriptContextCache, now: Date.now() });
|
|
2600
|
+
this.transcriptContextCache = derived.cache;
|
|
2601
|
+
if (derived.context && (!probeContext || derived.context.lastUpdatedAt > probeContext.lastUpdatedAt)) {
|
|
2602
|
+
context = derived.context;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2329
2605
|
const quota = probeMetrics ? quotaStateFromProbeMetrics(probeMetrics) : undefined;
|
|
2330
2606
|
const terminalSession = providerTerminalSession(this.process);
|
|
2331
2607
|
const terminalSocket = providerTerminalSocket(this.process);
|