@gajae-code/coding-agent 0.18.1 → 0.18.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/CHANGELOG.md +161 -0
- package/dist/types/cli/stats-cli.d.ts +0 -5
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/autorouting-tier-map.d.ts +29 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/model-registry.d.ts +16 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exa/mcp-client.d.ts +1 -5
- package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/hashline/parser.d.ts +28 -5
- package/dist/types/modes/acp/acp-agent.d.ts +6 -1
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
- package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
- package/dist/types/sdk/broker/runtime.d.ts +21 -1
- package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
- package/dist/types/sdk/bus/index.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +7 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
- package/dist/types/sdk/router/session-router.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -0
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/stats-cli.ts +4 -36
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +4 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -186
- package/src/commands/commit.ts +2 -2
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +60 -2
- package/src/commands/update.ts +15 -1
- package/src/config/autorouting-tier-map.ts +20 -2
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-preset-registry.ts +17 -2
- package/src/config/model-profile-activation.ts +73 -5
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +29 -29
- package/src/config/model-registry.ts +45 -17
- package/src/config/model-resolver.ts +9 -1
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exa/mcp-client.ts +41 -8
- package/src/exec/bash-shell-guardian.ts +184 -34
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/hashline/execute.ts +51 -3
- package/src/hashline/parser.ts +63 -16
- package/src/internal-urls/docs-index.generated.ts +29 -28
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +688 -371
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/controllers/ssh-command-controller.ts +3 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/sdk/broker/broker.ts +46 -0
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +108 -34
- package/src/sdk/broker/recovery-backoff.ts +135 -0
- package/src/sdk/broker/runtime.ts +88 -1
- package/src/sdk/broker/startup-budget.ts +6 -1
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +305 -19
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +34 -1
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +40 -2
- package/src/sdk/host/session-runtime.ts +112 -8
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/prompt-watchdog.ts +17 -4
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +7 -1
- package/src/sdk/session.ts +37 -8
- package/src/session/agent-session.ts +128 -41
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +119 -3
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +77 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
- package/dist/types/cli/grep-cli.d.ts +0 -17
- package/dist/types/cli/shell-cli.d.ts +0 -8
- package/dist/types/commands/grep.d.ts +0 -42
- package/dist/types/commands/shell.d.ts +0 -21
- package/src/cli/grep-cli.ts +0 -160
- package/src/cli/shell-cli.ts +0 -176
- package/src/commands/grep.ts +0 -48
- package/src/commands/shell.ts +0 -29
|
@@ -162,6 +162,13 @@ export const ACP_SESSION_READINESS_TIMEOUT_MS = 22_000;
|
|
|
162
162
|
const ACP_FIRST_PROMPT_MAX_RETRIES = 2;
|
|
163
163
|
/** Backoff before each first-prompt retry, scaled by attempt (250ms, then 500ms). */
|
|
164
164
|
const ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS = 250;
|
|
165
|
+
const ACP_BUSY_SETTLE_WAIT_MS = 5_000;
|
|
166
|
+
/**
|
|
167
|
+
* Default model settlement timeout: when no --model is passed but modelRoles.default is configured,
|
|
168
|
+
* we wait for the SDK to apply the default before answering session/new, so the model option is present.
|
|
169
|
+
* The SDK applies defaults synchronously for known models, so this is a bounded safety timeout.
|
|
170
|
+
*/
|
|
171
|
+
const ACP_MODEL_SETTLEMENT_TIMEOUT_MS = 500;
|
|
165
172
|
|
|
166
173
|
type JsonObject = Record<string, unknown>;
|
|
167
174
|
interface PromptWaiter {
|
|
@@ -230,6 +237,8 @@ interface PromptWaiter {
|
|
|
230
237
|
cancelAttemptResolve?: (acknowledged: boolean) => void;
|
|
231
238
|
resolve: (response: PromptResponse) => void;
|
|
232
239
|
reject: (error: Error) => void;
|
|
240
|
+
/** Resolves when the prompt response settles, regardless of success or failure. */
|
|
241
|
+
settlement: Promise<void>;
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
type PromptCorrelation = { commandId?: string; turnId?: string };
|
|
@@ -247,6 +256,14 @@ type PromptPhaseOwner = PromptWaiter | "background" | undefined;
|
|
|
247
256
|
type BrokerConnection = { adapter: AcpSdkAdapter; client: SdkClient };
|
|
248
257
|
type PendingAttachment = { epoch: number; task: Promise<void> };
|
|
249
258
|
|
|
259
|
+
type PromptAdmissionSettlement = { kind: "cancelled" } | { kind: "rejected"; error: AcpSdkAdapterError };
|
|
260
|
+
type PromptAdmissionReservation = {
|
|
261
|
+
cancelled?: boolean;
|
|
262
|
+
settlement?: PromptAdmissionSettlement;
|
|
263
|
+
wake: () => void;
|
|
264
|
+
woken: Promise<void>;
|
|
265
|
+
};
|
|
266
|
+
|
|
250
267
|
type SessionRecord = {
|
|
251
268
|
cwd: string;
|
|
252
269
|
adapter: AcpSdkAdapter;
|
|
@@ -263,9 +280,12 @@ type SessionRecord = {
|
|
|
263
280
|
inboundSequence: number;
|
|
264
281
|
/** Updated at ingress so a prompt acknowledgement can distinguish a steer from a fresh turn. */
|
|
265
282
|
busy: boolean;
|
|
283
|
+
/** Last SDK activity state; ACP prompt settlement can precede the SDK's idle publication. */
|
|
284
|
+
sdkIdle: boolean;
|
|
266
285
|
backgroundBusy: boolean;
|
|
267
286
|
backgroundAnonymousCount: number;
|
|
268
287
|
backgroundCorrelations: PromptCorrelation[];
|
|
288
|
+
idleWaiters: Set<() => void>;
|
|
269
289
|
/** Start/update args retained because tool_execution_end does not carry them. */
|
|
270
290
|
toolArgs: Map<string, unknown>;
|
|
271
291
|
/** Message projection state for correlationless session-scoped assistant events. */
|
|
@@ -295,6 +315,8 @@ type SessionRecord = {
|
|
|
295
315
|
promptObservedAssistantOutput?: boolean;
|
|
296
316
|
/** Owner token held across the first-turn retry backoff; a non-owner prompt is rejected as a conflict (review P1). */
|
|
297
317
|
pendingFirstPromptRetry?: FirstPromptRetryReservation;
|
|
318
|
+
/** Sole successor admitted while a cancelled prompt settles and publication tails drain. */
|
|
319
|
+
pendingPromptAdmission?: PromptAdmissionReservation;
|
|
298
320
|
};
|
|
299
321
|
|
|
300
322
|
/**
|
|
@@ -667,7 +689,9 @@ function logDroppedPromptTerminal(
|
|
|
667
689
|
});
|
|
668
690
|
}
|
|
669
691
|
|
|
670
|
-
type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }
|
|
692
|
+
type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }> & {
|
|
693
|
+
deliveryFailure?: { cause: string; frameBytes: number };
|
|
694
|
+
};
|
|
671
695
|
|
|
672
696
|
/**
|
|
673
697
|
* A prompt rejection that still carries the terminal's structured failure classification.
|
|
@@ -803,6 +827,13 @@ function promptFailureWireData(failure: SdkPromptFailedOutcome): Record<string,
|
|
|
803
827
|
category: failure.category,
|
|
804
828
|
retryability: promptFailureRetryability(failure.category),
|
|
805
829
|
...(isSafePromptFailureCode(failure.providerCode) ? { providerCode: failure.providerCode } : {}),
|
|
830
|
+
...(failure.deliveryFailure
|
|
831
|
+
? {
|
|
832
|
+
reason: "delivery_failed",
|
|
833
|
+
cause: failure.deliveryFailure.cause,
|
|
834
|
+
frameBytes: String(failure.deliveryFailure.frameBytes),
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
806
837
|
...(operatorMessage === undefined ? {} : { operatorMessage }),
|
|
807
838
|
};
|
|
808
839
|
}
|
|
@@ -921,13 +952,28 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
|
|
|
921
952
|
typeof outcome.message === "string" &&
|
|
922
953
|
(outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
|
|
923
954
|
) {
|
|
924
|
-
|
|
955
|
+
const failure = failedPromptOutcome({
|
|
925
956
|
code: outcome.code,
|
|
926
957
|
provenance: outcome.provenance,
|
|
927
958
|
...(typeof outcome.providerCode === "string" ? { providerCode: outcome.providerCode } : {}),
|
|
928
959
|
...(isSdkPromptFailurePhase(outcome.phase) ? { phase: outcome.phase } : {}),
|
|
929
960
|
evidence: {},
|
|
930
961
|
});
|
|
962
|
+
const delivery = object(event.error);
|
|
963
|
+
if (
|
|
964
|
+
delivery?.code === "delivery_failed" &&
|
|
965
|
+
isSafePromptFailureCode(delivery.cause) &&
|
|
966
|
+
typeof delivery.frameBytes === "number" &&
|
|
967
|
+
Number.isSafeInteger(delivery.frameBytes) &&
|
|
968
|
+
delivery.frameBytes >= 0
|
|
969
|
+
) {
|
|
970
|
+
const classified: SdkPromptFailedOutcome = {
|
|
971
|
+
...failure,
|
|
972
|
+
deliveryFailure: { cause: delivery.cause, frameBytes: delivery.frameBytes },
|
|
973
|
+
};
|
|
974
|
+
return classified;
|
|
975
|
+
}
|
|
976
|
+
return failure;
|
|
931
977
|
}
|
|
932
978
|
return undefined;
|
|
933
979
|
}
|
|
@@ -1115,6 +1161,15 @@ function frameMessageRole(event: JsonObject | undefined): string | undefined {
|
|
|
1115
1161
|
return typeof role === "string" ? role : undefined;
|
|
1116
1162
|
}
|
|
1117
1163
|
|
|
1164
|
+
function frameMessageHasToolCall(event: JsonObject | undefined): boolean {
|
|
1165
|
+
const content = object(event?.message)?.content;
|
|
1166
|
+
if (!Array.isArray(content)) return false;
|
|
1167
|
+
return content.some(block => {
|
|
1168
|
+
const value = object(block);
|
|
1169
|
+
return value?.type === "toolCall" || value?.type === "tool_use";
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1118
1173
|
const ACP_CONFIG_OPTIONS = [
|
|
1119
1174
|
{ id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
|
|
1120
1175
|
{ id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
|
|
@@ -1716,6 +1771,10 @@ export class AcpAgent implements Agent {
|
|
|
1716
1771
|
this.#agentDir = typeof candidate?.agentDir === "string" ? candidate.agentDir : getAgentDir();
|
|
1717
1772
|
this.#router = new SessionRouter({
|
|
1718
1773
|
agentDir: this.#agentDir,
|
|
1774
|
+
attachFilter: sessionId =>
|
|
1775
|
+
this.#sessions.has(sessionId) ||
|
|
1776
|
+
this.#pendingRouterAdapters.has(sessionId) ||
|
|
1777
|
+
this.#pendingRouterFrames.has(sessionId),
|
|
1719
1778
|
deps: {
|
|
1720
1779
|
onAttachment: attachment => {
|
|
1721
1780
|
const record = this.#sessions.get(attachment.sessionId);
|
|
@@ -1815,33 +1874,62 @@ export class AcpAgent implements Agent {
|
|
|
1815
1874
|
const mcpServers = this.#mcpServers(params);
|
|
1816
1875
|
this.#assertAbsoluteCwd(params.cwd);
|
|
1817
1876
|
this.#assertNoAdditionalDirectories(params.additionalDirectories);
|
|
1818
|
-
const
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
cwd: params.cwd,
|
|
1822
|
-
target: { path: params.cwd },
|
|
1823
|
-
...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
|
|
1824
|
-
readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
|
|
1825
|
-
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
1826
|
-
},
|
|
1827
|
-
randomUUID(),
|
|
1828
|
-
mcpServers,
|
|
1829
|
-
);
|
|
1830
|
-
const id = sessionId(result);
|
|
1831
|
-
this.#knownSessionCwds.set(id, params.cwd);
|
|
1832
|
-
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1833
|
-
// This connection launched the host, so it owns the broker lifecycle before the
|
|
1834
|
-
// attachment reports a control surface. Without it a failed attach discards the
|
|
1835
|
-
// session as unowned and masks the real error with cleanup uncertainty.
|
|
1836
|
-
this.#ownedSessionIds.add(id);
|
|
1877
|
+
const startedAt = performance.now();
|
|
1878
|
+
const phases: Record<string, number> = {};
|
|
1879
|
+
let id: string | undefined;
|
|
1837
1880
|
try {
|
|
1881
|
+
let phaseStartedAt = performance.now();
|
|
1882
|
+
const result = await this.#launchSessionWithMcp(
|
|
1883
|
+
"session.create",
|
|
1884
|
+
{
|
|
1885
|
+
cwd: params.cwd,
|
|
1886
|
+
target: { path: params.cwd },
|
|
1887
|
+
...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
|
|
1888
|
+
readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
|
|
1889
|
+
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
1890
|
+
},
|
|
1891
|
+
randomUUID(),
|
|
1892
|
+
mcpServers,
|
|
1893
|
+
);
|
|
1894
|
+
phases.launchMs = performance.now() - phaseStartedAt;
|
|
1895
|
+
id = sessionId(result);
|
|
1896
|
+
this.#knownSessionCwds.set(id, params.cwd);
|
|
1897
|
+
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1898
|
+
// This connection launched the host, so it owns the broker lifecycle before the
|
|
1899
|
+
// attachment reports a control surface. Without it a failed attach discards the
|
|
1900
|
+
// session as unowned and masks the real error with cleanup uncertainty.
|
|
1901
|
+
this.#ownedSessionIds.add(id);
|
|
1902
|
+
phaseStartedAt = performance.now();
|
|
1838
1903
|
await this.#attach(id, params.cwd, undefined, result);
|
|
1904
|
+
phases.attachMs = performance.now() - phaseStartedAt;
|
|
1905
|
+
phaseStartedAt = performance.now();
|
|
1839
1906
|
await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
|
|
1907
|
+
phases.startupOptionsMs = performance.now() - phaseStartedAt;
|
|
1908
|
+
phaseStartedAt = performance.now();
|
|
1909
|
+
await this.#waitForModelSettle(id);
|
|
1910
|
+
phases.modelSettleMs = performance.now() - phaseStartedAt;
|
|
1911
|
+
phaseStartedAt = performance.now();
|
|
1840
1912
|
const response = { sessionId: id, ...(await this.#sessionState(id, true)) };
|
|
1913
|
+
phases.sessionStateMs = performance.now() - phaseStartedAt;
|
|
1841
1914
|
this.#scheduleBootstrap(id);
|
|
1915
|
+
logger.debug("acp_session_new_phases", {
|
|
1916
|
+
sessionId: id,
|
|
1917
|
+
...phases,
|
|
1918
|
+
outcome: "ok",
|
|
1919
|
+
totalMs: performance.now() - startedAt,
|
|
1920
|
+
});
|
|
1842
1921
|
return response;
|
|
1843
1922
|
} catch (error) {
|
|
1844
|
-
|
|
1923
|
+
try {
|
|
1924
|
+
if (id !== undefined) await this.#discardNewSession(id);
|
|
1925
|
+
} finally {
|
|
1926
|
+
logger.debug("acp_session_new_phases", {
|
|
1927
|
+
...(id !== undefined ? { sessionId: id } : {}),
|
|
1928
|
+
...phases,
|
|
1929
|
+
outcome: "error",
|
|
1930
|
+
totalMs: performance.now() - startedAt,
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1845
1933
|
throw error;
|
|
1846
1934
|
}
|
|
1847
1935
|
}
|
|
@@ -2244,6 +2332,70 @@ export class AcpAgent implements Agent {
|
|
|
2244
2332
|
): Promise<PromptResponse> {
|
|
2245
2333
|
const record = this.#sessions.get(params.sessionId);
|
|
2246
2334
|
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
|
|
2335
|
+
let admissionReservation: PromptAdmissionReservation | undefined;
|
|
2336
|
+
if (record.pendingPromptAdmission)
|
|
2337
|
+
throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2338
|
+
const activePrompt = record.activePrompt;
|
|
2339
|
+
if (activePrompt) {
|
|
2340
|
+
const cancellationPending =
|
|
2341
|
+
record.cancelRequested ||
|
|
2342
|
+
activePrompt.cancelAttempt !== undefined ||
|
|
2343
|
+
activePrompt.cancelAcknowledged === true;
|
|
2344
|
+
if (!cancellationPending || record.pendingPromptAdmission)
|
|
2345
|
+
throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2346
|
+
const { promise: woken, resolve: wake } = Promise.withResolvers<void>();
|
|
2347
|
+
admissionReservation = { wake, woken };
|
|
2348
|
+
record.pendingPromptAdmission = admissionReservation;
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
if (admissionReservation) {
|
|
2352
|
+
const settled = await this.#waitForPromptSettlement(activePrompt as PromptWaiter, admissionReservation);
|
|
2353
|
+
const settlement = admissionReservation.settlement;
|
|
2354
|
+
if (settlement?.kind === "rejected") throw settlement.error;
|
|
2355
|
+
if (settlement?.kind === "cancelled" || admissionReservation.cancelled) return { stopReason: "cancelled" };
|
|
2356
|
+
if (!settled) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2357
|
+
await this.#drainPromptPublicationTails(params.sessionId);
|
|
2358
|
+
if (admissionReservation.settlement?.kind === "rejected") throw admissionReservation.settlement.error;
|
|
2359
|
+
if (admissionReservation.settlement?.kind === "cancelled" || admissionReservation.cancelled)
|
|
2360
|
+
return { stopReason: "cancelled" };
|
|
2361
|
+
}
|
|
2362
|
+
return await this.#submitPromptCore(params, echoUserMessage, retryReservation, admissionReservation);
|
|
2363
|
+
} finally {
|
|
2364
|
+
if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
async #waitForPromptSettlement(
|
|
2369
|
+
waiter: PromptWaiter,
|
|
2370
|
+
admissionReservation: PromptAdmissionReservation,
|
|
2371
|
+
): Promise<boolean> {
|
|
2372
|
+
let cancelTimer: (() => void) | undefined;
|
|
2373
|
+
const timeout = new Promise<boolean>(resolve => {
|
|
2374
|
+
cancelTimer = this.#promptWatchdogClock.schedule(() => resolve(false), ACP_BUSY_SETTLE_WAIT_MS);
|
|
2375
|
+
});
|
|
2376
|
+
try {
|
|
2377
|
+
return await Promise.race([
|
|
2378
|
+
waiter.settlement.then(() => true),
|
|
2379
|
+
timeout,
|
|
2380
|
+
admissionReservation.woken.then(() => true),
|
|
2381
|
+
]);
|
|
2382
|
+
} finally {
|
|
2383
|
+
cancelTimer?.();
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
async #submitPromptCore(
|
|
2388
|
+
params: PromptRequest,
|
|
2389
|
+
echoUserMessage: boolean,
|
|
2390
|
+
retryReservation?: FirstPromptRetryReservation,
|
|
2391
|
+
admissionReservation?: PromptAdmissionReservation,
|
|
2392
|
+
): Promise<PromptResponse> {
|
|
2393
|
+
const admissionSettlement = admissionReservation?.settlement;
|
|
2394
|
+
if (admissionSettlement?.kind === "rejected") throw admissionSettlement.error;
|
|
2395
|
+
if (admissionSettlement?.kind === "cancelled" || admissionReservation?.cancelled)
|
|
2396
|
+
return { stopReason: "cancelled" };
|
|
2397
|
+
const record = this.#sessions.get(params.sessionId);
|
|
2398
|
+
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
|
|
2247
2399
|
// A first-turn retry holds the session across its backoff gap. Any prompt that is not the
|
|
2248
2400
|
// retry owner is rejected here, before any state mutation or dispatch, so it cannot steal
|
|
2249
2401
|
// the first turn while the authorized retry is still pending (review P1, issue #5574).
|
|
@@ -2329,6 +2481,11 @@ export class AcpAgent implements Agent {
|
|
|
2329
2481
|
record.promptObservedToolExecution = false;
|
|
2330
2482
|
record.promptObservedAssistantOutput = false;
|
|
2331
2483
|
const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
|
|
2484
|
+
const { promise: settlement, resolve: completeOwnership } = Promise.withResolvers<void>();
|
|
2485
|
+
void response.then(
|
|
2486
|
+
() => completeOwnership(),
|
|
2487
|
+
() => completeOwnership(),
|
|
2488
|
+
);
|
|
2332
2489
|
const waiter: PromptWaiter = {
|
|
2333
2490
|
invocationKind: skillInvocation ? "skill" : "prompt",
|
|
2334
2491
|
clientRef,
|
|
@@ -2349,31 +2506,53 @@ export class AcpAgent implements Agent {
|
|
|
2349
2506
|
activity: new PromptActivity(),
|
|
2350
2507
|
observedTurnActivity: false,
|
|
2351
2508
|
observedToolExecution: false,
|
|
2509
|
+
settlement,
|
|
2352
2510
|
resolve,
|
|
2353
2511
|
reject,
|
|
2354
2512
|
};
|
|
2355
|
-
record.activePrompt = waiter;
|
|
2356
|
-
// The watchdog may reject this waiter while the SDK acknowledgement request is still
|
|
2357
|
-
// pending. Retain the original promise for the caller, but mark that delayed rejection
|
|
2358
|
-
// as observed until prompt() can resume and await it.
|
|
2359
|
-
void response.catch(() => undefined);
|
|
2360
|
-
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
2361
|
-
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
2362
|
-
// Provider preflight is part of that window — it awaits a lease the host may never win
|
|
2363
|
-
// — so the bound is armed before it, not after (issue #5658). The arm below re-arms
|
|
2364
|
-
// this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
|
|
2365
|
-
// timer first, so only one timer is ever live.
|
|
2366
|
-
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2367
|
-
// The watchdog settles `response`, not this await, so a preflight that never returns
|
|
2368
|
-
// would pin session/prompt open with the caller's promise already rejected. Racing the
|
|
2369
|
-
// settlement lets the re-checks below hand that rejection back to the caller.
|
|
2370
|
-
const settlement = response.then(
|
|
2371
|
-
() => undefined,
|
|
2372
|
-
() => undefined,
|
|
2373
|
-
);
|
|
2374
2513
|
try {
|
|
2375
|
-
|
|
2376
|
-
|
|
2514
|
+
record.activePrompt = waiter;
|
|
2515
|
+
if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
|
|
2516
|
+
// The watchdog may reject this waiter while the SDK acknowledgement request is still
|
|
2517
|
+
// pending. Retain the original promise for the caller, but mark that delayed rejection
|
|
2518
|
+
// as observed until prompt() can resume and await it.
|
|
2519
|
+
void response.catch(() => undefined);
|
|
2520
|
+
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
2521
|
+
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
2522
|
+
// Provider preflight is part of that window — it awaits a lease the host may never win
|
|
2523
|
+
// — so the bound is armed before it, not after (issue #5658). The arm below re-arms
|
|
2524
|
+
// this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
|
|
2525
|
+
// timer first, so only one timer is ever live.
|
|
2526
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2527
|
+
// The watchdog settles `response`, not this await, so a preflight that never returns
|
|
2528
|
+
// would pin session/prompt open with the caller's promise already rejected. Racing the
|
|
2529
|
+
// settlement lets the re-checks below hand that rejection back to the caller.
|
|
2530
|
+
try {
|
|
2531
|
+
await Promise.race([record.adapter.ensureProviders(), settlement]);
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
waiter.awaitingProviderPreflight = false;
|
|
2534
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2535
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2536
|
+
return await response;
|
|
2537
|
+
}
|
|
2538
|
+
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2539
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2540
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2541
|
+
return await response;
|
|
2542
|
+
}
|
|
2543
|
+
if (record.activePrompt === waiter) {
|
|
2544
|
+
record.activePrompt = undefined;
|
|
2545
|
+
record.busy = record.backgroundBusy;
|
|
2546
|
+
clearPromptWatchdog(waiter);
|
|
2547
|
+
// #5526's guarded release replaces the unguarded delete: it only clears
|
|
2548
|
+
// the entry when it still belongs to THIS waiter, so a successor's
|
|
2549
|
+
// acknowledgement is never dropped. Strictly narrower than the delete
|
|
2550
|
+
// it supersedes; dev's watchdog clear above is unaffected.
|
|
2551
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2552
|
+
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2553
|
+
}
|
|
2554
|
+
throw error;
|
|
2555
|
+
}
|
|
2377
2556
|
waiter.awaitingProviderPreflight = false;
|
|
2378
2557
|
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2379
2558
|
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
@@ -2384,354 +2563,370 @@ export class AcpAgent implements Agent {
|
|
|
2384
2563
|
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2385
2564
|
return await response;
|
|
2386
2565
|
}
|
|
2387
|
-
if (record.activePrompt === waiter) {
|
|
2388
|
-
record.activePrompt = undefined;
|
|
2389
|
-
record.busy = record.backgroundBusy;
|
|
2390
|
-
clearPromptWatchdog(waiter);
|
|
2391
|
-
// #5526's guarded release replaces the unguarded delete: it only clears
|
|
2392
|
-
// the entry when it still belongs to THIS waiter, so a successor's
|
|
2393
|
-
// acknowledgement is never dropped. Strictly narrower than the delete
|
|
2394
|
-
// it supersedes; dev's watchdog clear above is unaffected.
|
|
2395
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2396
|
-
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2397
|
-
}
|
|
2398
|
-
throw error;
|
|
2399
|
-
}
|
|
2400
|
-
waiter.awaitingProviderPreflight = false;
|
|
2401
|
-
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2402
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2403
|
-
return await response;
|
|
2404
|
-
}
|
|
2405
|
-
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2406
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2407
|
-
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2408
|
-
return await response;
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
// Re-arm now that preflight is behind us: the bound armed before it already covered
|
|
2412
|
-
// this prompt's ownership window, and this call replaces that timer rather than
|
|
2413
|
-
// racing it, so the dispatched turn gets the full inactivity bound of its own.
|
|
2414
|
-
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2415
|
-
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
2416
|
-
// transcript from session/update, so without this a prompt's text and any attached
|
|
2417
|
-
// image never appear in the client UI — only the agent's reply does. Replay
|
|
2418
|
-
// (session/load) already emits these; a live turn must too, and the image blocks
|
|
2419
|
-
// must be published verbatim so attachments are visible, not just fed to the model.
|
|
2420
|
-
// A first-turn readiness retry (issue #5574) skips the echo: the message was already
|
|
2421
|
-
// published on the first attempt and re-echoing would duplicate it in the transcript.
|
|
2422
|
-
if (echoUserMessage)
|
|
2423
|
-
for (const block of params.prompt) {
|
|
2424
|
-
if (block.type !== "text" && block.type !== "image") continue;
|
|
2425
|
-
if (block.type === "text" && block.text.length === 0) continue;
|
|
2426
|
-
await this.#publishSessionUpdate(
|
|
2427
|
-
params.sessionId,
|
|
2428
|
-
{
|
|
2429
|
-
sessionId: params.sessionId,
|
|
2430
|
-
update: { sessionUpdate: "user_message_chunk", content: block },
|
|
2431
|
-
},
|
|
2432
|
-
record.adapter,
|
|
2433
|
-
);
|
|
2434
|
-
}
|
|
2435
|
-
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2436
|
-
return await response;
|
|
2437
|
-
}
|
|
2438
|
-
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2439
|
-
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2440
|
-
return await response;
|
|
2441
|
-
}
|
|
2442
|
-
waiter.dispatched = true;
|
|
2443
|
-
// The turn is now dispatched to the host: this prompt owned the session's first turn,
|
|
2444
|
-
// so it — not a preflight rejection that threw before this point — is what settles
|
|
2445
|
-
// `firstPromptDone` in `prompt()` (review P2).
|
|
2446
|
-
if (retryReservation) retryReservation.admitted = true;
|
|
2447
|
-
|
|
2448
|
-
waiter.acknowledgementPending = true;
|
|
2449
|
-
const promptAdapter = record.adapter;
|
|
2450
|
-
const acknowledgementTask = (async (): Promise<PromptResponse> => {
|
|
2451
|
-
if (waiter.settled || record.activePrompt !== waiter) return await response;
|
|
2452
|
-
const acknowledgement = skillInvocation
|
|
2453
|
-
? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
|
|
2454
|
-
: await promptAdapter.prompt({
|
|
2455
|
-
text: payload.text,
|
|
2456
|
-
clientRef,
|
|
2457
|
-
...(payload.images.length ? { images: payload.images } : {}),
|
|
2458
|
-
});
|
|
2459
|
-
// A recovered waiter already owns its exact identity; a late ack cannot rebind it.
|
|
2460
|
-
if (waiter.settled && waiter.acknowledged) return await response;
|
|
2461
2566
|
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
const
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
provisionalOwner.correlation === undefined &&
|
|
2479
|
-
this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
|
|
2480
|
-
) {
|
|
2481
|
-
// A stale ACK cannot bind a provisional abort owner. Keep the fence
|
|
2482
|
-
// until this session is retired or new exact evidence is available.
|
|
2483
|
-
waiter.deferredFrames.length = 0;
|
|
2484
|
-
return await response;
|
|
2485
|
-
}
|
|
2486
|
-
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2487
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2488
|
-
const retainedOwner = current?.uncertainAbortOwner;
|
|
2489
|
-
if (
|
|
2490
|
-
current &&
|
|
2491
|
-
retainedOwner?.kind === waiter.invocationKind &&
|
|
2492
|
-
retainedOwner.clientRef === waiter.clientRef &&
|
|
2493
|
-
retainedOwner.correlation &&
|
|
2494
|
-
correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
|
|
2495
|
-
) {
|
|
2496
|
-
await current.frameTail;
|
|
2497
|
-
const deferredFrames = waiter.deferredFrames.splice(0);
|
|
2498
|
-
let reconciledDeferredTerminal = false;
|
|
2499
|
-
for (const deferred of deferredFrames) {
|
|
2500
|
-
const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
|
|
2501
|
-
if (!deferredEvent) continue;
|
|
2502
|
-
const deferredOutcome = terminalOutcome(deferredEvent);
|
|
2503
|
-
const validDeferredTerminal =
|
|
2504
|
-
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2505
|
-
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2506
|
-
const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
|
|
2507
|
-
if (
|
|
2508
|
-
!validDeferredTerminal ||
|
|
2509
|
-
!sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
|
|
2510
|
-
!deferredCorrelation ||
|
|
2511
|
-
!hasCompleteCorrelation(deferredCorrelation) ||
|
|
2512
|
-
!correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
|
|
2513
|
-
)
|
|
2514
|
-
continue;
|
|
2515
|
-
waiter.terminalReserved = true;
|
|
2516
|
-
await this.#handleSdkFrame(
|
|
2517
|
-
params.sessionId,
|
|
2518
|
-
current.adapter,
|
|
2519
|
-
deferred.frame,
|
|
2520
|
-
current.publicationGeneration,
|
|
2521
|
-
);
|
|
2522
|
-
reconciledDeferredTerminal = true;
|
|
2523
|
-
break;
|
|
2524
|
-
}
|
|
2525
|
-
if (!reconciledDeferredTerminal)
|
|
2526
|
-
void this.#recoverUncertainAbortAfterLateAcknowledgement(
|
|
2527
|
-
params.sessionId,
|
|
2528
|
-
current.adapter,
|
|
2529
|
-
retainedOwner,
|
|
2530
|
-
);
|
|
2531
|
-
} else {
|
|
2532
|
-
waiter.deferredFrames.length = 0;
|
|
2533
|
-
}
|
|
2534
|
-
return await response;
|
|
2535
|
-
}
|
|
2536
|
-
if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
|
|
2537
|
-
try {
|
|
2538
|
-
const abortAcknowledgement = await record.adapter.cancel("turn");
|
|
2539
|
-
if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
|
|
2540
|
-
throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
|
|
2541
|
-
} catch (error) {
|
|
2542
|
-
await this.#failSession(
|
|
2567
|
+
// Re-arm now that preflight is behind us: the bound armed before it already covered
|
|
2568
|
+
// this prompt's ownership window, and this call replaces that timer rather than
|
|
2569
|
+
// racing it, so the dispatched turn gets the full inactivity bound of its own.
|
|
2570
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2571
|
+
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
2572
|
+
// transcript from session/update, so without this a prompt's text and any attached
|
|
2573
|
+
// image never appear in the client UI — only the agent's reply does. Replay
|
|
2574
|
+
// (session/load) already emits these; a live turn must too, and the image blocks
|
|
2575
|
+
// must be published verbatim so attachments are visible, not just fed to the model.
|
|
2576
|
+
// A first-turn readiness retry (issue #5574) skips the echo: the message was already
|
|
2577
|
+
// published on the first attempt and re-echoing would duplicate it in the transcript.
|
|
2578
|
+
if (echoUserMessage)
|
|
2579
|
+
for (const block of params.prompt) {
|
|
2580
|
+
if (block.type !== "text" && block.type !== "image") continue;
|
|
2581
|
+
if (block.type === "text" && block.text.length === 0) continue;
|
|
2582
|
+
await this.#publishSessionUpdate(
|
|
2543
2583
|
params.sessionId,
|
|
2584
|
+
{
|
|
2585
|
+
sessionId: params.sessionId,
|
|
2586
|
+
update: { sessionUpdate: "user_message_chunk", content: block },
|
|
2587
|
+
},
|
|
2544
2588
|
record.adapter,
|
|
2545
|
-
new AcpSdkAdapterError(
|
|
2546
|
-
"connection_closed",
|
|
2547
|
-
`ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
|
|
2548
|
-
),
|
|
2549
2589
|
);
|
|
2550
|
-
throw error;
|
|
2551
2590
|
}
|
|
2552
|
-
|
|
2553
|
-
"invalid_prompt_acknowledgement",
|
|
2554
|
-
"SDK prompt acknowledgement reused a settled commandId/turnId pair.",
|
|
2555
|
-
);
|
|
2556
|
-
}
|
|
2557
|
-
// Retain the acknowledgement ingress boundary with its complete correlation.
|
|
2558
|
-
waiter.boundary = record.inboundSequence;
|
|
2559
|
-
waiter.correlation = acknowledgementCorrelation;
|
|
2560
|
-
waiter.acknowledged = true;
|
|
2561
|
-
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2562
|
-
if (promptWaiterRetired(record, waiter)) {
|
|
2563
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2591
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2564
2592
|
return await response;
|
|
2565
2593
|
}
|
|
2566
|
-
if (
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
void this.cancel({
|
|
2570
|
-
sessionId: params.sessionId,
|
|
2571
|
-
_meta: { gjc: { abortScope: scope } },
|
|
2572
|
-
} as CancelNotification).catch(error =>
|
|
2573
|
-
logger.warn("ACP could not retry a cancel after prompt admission", {
|
|
2574
|
-
sessionId: params.sessionId,
|
|
2575
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2576
|
-
}),
|
|
2577
|
-
);
|
|
2594
|
+
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2595
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2596
|
+
return await response;
|
|
2578
2597
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
if (
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2598
|
+
waiter.dispatched = true;
|
|
2599
|
+
record.sdkIdle = false;
|
|
2600
|
+
// The turn is now dispatched to the host: this prompt owned the session's first turn,
|
|
2601
|
+
// so it — not a preflight rejection that threw before this point — is what settles
|
|
2602
|
+
// `firstPromptDone` in `prompt()` (review P2).
|
|
2603
|
+
if (retryReservation) retryReservation.admitted = true;
|
|
2604
|
+
|
|
2605
|
+
waiter.acknowledgementPending = true;
|
|
2606
|
+
const promptAdapter = record.adapter;
|
|
2607
|
+
const acknowledgementTask = (async (): Promise<PromptResponse> => {
|
|
2608
|
+
if (waiter.settled || record.activePrompt !== waiter) return await response;
|
|
2609
|
+
const submit = async (): Promise<unknown> =>
|
|
2610
|
+
skillInvocation
|
|
2611
|
+
? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
|
|
2612
|
+
: await promptAdapter.prompt({
|
|
2613
|
+
text: payload.text,
|
|
2614
|
+
clientRef,
|
|
2615
|
+
...(payload.images.length ? { images: payload.images } : {}),
|
|
2616
|
+
});
|
|
2617
|
+
let acknowledgement: unknown;
|
|
2618
|
+
try {
|
|
2619
|
+
acknowledgement = await submit();
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
if (!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) || error.code !== "busy")
|
|
2622
|
+
throw error;
|
|
2623
|
+
if (
|
|
2624
|
+
record.activePrompt !== waiter ||
|
|
2625
|
+
waiter.settled ||
|
|
2626
|
+
record.cancelRequested ||
|
|
2627
|
+
waiter.cancelAcknowledged ||
|
|
2628
|
+
waiter.cancelBeforeAdmission
|
|
2629
|
+
)
|
|
2630
|
+
throw error;
|
|
2631
|
+
const ready = await this.#waitForSessionIdle(record);
|
|
2632
|
+
if (!ready) throw error;
|
|
2633
|
+
if (
|
|
2634
|
+
record.activePrompt !== waiter ||
|
|
2635
|
+
waiter.settled ||
|
|
2636
|
+
record.cancelRequested ||
|
|
2637
|
+
waiter.cancelAcknowledged ||
|
|
2638
|
+
waiter.cancelBeforeAdmission
|
|
2639
|
+
)
|
|
2640
|
+
throw error;
|
|
2641
|
+
acknowledgement = await submit();
|
|
2642
|
+
}
|
|
2643
|
+
// A recovered waiter already owns its exact identity; a late ack cannot rebind it.
|
|
2644
|
+
if (waiter.settled && waiter.acknowledged) return await response;
|
|
2645
|
+
|
|
2646
|
+
const acknowledgementCorrelation = promptAcknowledgement(acknowledgement);
|
|
2647
|
+
if (!acknowledgementCorrelation)
|
|
2648
|
+
throw new AcpSdkAdapterError(
|
|
2649
|
+
"invalid_prompt_acknowledgement",
|
|
2650
|
+
"SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
|
|
2651
|
+
);
|
|
2652
|
+
if (
|
|
2653
|
+
this.#sessions.get(params.sessionId) !== record ||
|
|
2654
|
+
record.adapter !== promptAdapter ||
|
|
2655
|
+
record.activePrompt !== waiter
|
|
2656
|
+
) {
|
|
2657
|
+
const current = this.#sessions.get(params.sessionId);
|
|
2658
|
+
const provisionalOwner = current?.uncertainAbortOwner;
|
|
2659
|
+
if (
|
|
2660
|
+
provisionalOwner?.kind === waiter.invocationKind &&
|
|
2661
|
+
provisionalOwner.clientRef === waiter.clientRef &&
|
|
2662
|
+
provisionalOwner.correlation === undefined &&
|
|
2663
|
+
this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
|
|
2664
|
+
) {
|
|
2665
|
+
// A stale ACK cannot bind a provisional abort owner. Keep the fence
|
|
2666
|
+
// until this session is retired or new exact evidence is available.
|
|
2667
|
+
waiter.deferredFrames.length = 0;
|
|
2668
|
+
return await response;
|
|
2669
|
+
}
|
|
2670
|
+
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2671
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2672
|
+
const retainedOwner = current?.uncertainAbortOwner;
|
|
2673
|
+
if (
|
|
2674
|
+
current &&
|
|
2675
|
+
retainedOwner?.kind === waiter.invocationKind &&
|
|
2676
|
+
retainedOwner.clientRef === waiter.clientRef &&
|
|
2677
|
+
retainedOwner.correlation &&
|
|
2678
|
+
correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
|
|
2679
|
+
) {
|
|
2680
|
+
await current.frameTail;
|
|
2681
|
+
const deferredFrames = waiter.deferredFrames.splice(0);
|
|
2682
|
+
let reconciledDeferredTerminal = false;
|
|
2683
|
+
for (const deferred of deferredFrames) {
|
|
2684
|
+
const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
|
|
2685
|
+
if (!deferredEvent) continue;
|
|
2686
|
+
const deferredOutcome = terminalOutcome(deferredEvent);
|
|
2687
|
+
const validDeferredTerminal =
|
|
2688
|
+
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2689
|
+
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2690
|
+
const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
|
|
2599
2691
|
if (
|
|
2600
|
-
!
|
|
2601
|
-
|
|
2602
|
-
|
|
2692
|
+
!validDeferredTerminal ||
|
|
2693
|
+
!sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
|
|
2694
|
+
!deferredCorrelation ||
|
|
2695
|
+
!hasCompleteCorrelation(deferredCorrelation) ||
|
|
2696
|
+
!correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
|
|
2603
2697
|
)
|
|
2604
|
-
|
|
2605
|
-
|
|
2698
|
+
continue;
|
|
2699
|
+
waiter.terminalReserved = true;
|
|
2700
|
+
await this.#handleSdkFrame(
|
|
2701
|
+
params.sessionId,
|
|
2702
|
+
current.adapter,
|
|
2703
|
+
deferred.frame,
|
|
2704
|
+
current.publicationGeneration,
|
|
2705
|
+
);
|
|
2706
|
+
reconciledDeferredTerminal = true;
|
|
2707
|
+
break;
|
|
2708
|
+
}
|
|
2709
|
+
if (!reconciledDeferredTerminal)
|
|
2710
|
+
void this.#recoverUncertainAbortAfterLateAcknowledgement(
|
|
2711
|
+
params.sessionId,
|
|
2712
|
+
current.adapter,
|
|
2713
|
+
retainedOwner,
|
|
2714
|
+
);
|
|
2715
|
+
} else {
|
|
2716
|
+
waiter.deferredFrames.length = 0;
|
|
2606
2717
|
}
|
|
2718
|
+
return await response;
|
|
2607
2719
|
}
|
|
2608
|
-
if (
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2616
|
-
const deferred = waiter.deferredFrames.splice(0);
|
|
2617
|
-
for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
|
|
2618
|
-
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2619
|
-
if (!deferredEvent) continue;
|
|
2620
|
-
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
|
|
2621
|
-
const deferredOutcome =
|
|
2622
|
-
deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
|
|
2623
|
-
? terminalOutcome(deferredEvent)
|
|
2624
|
-
: undefined;
|
|
2625
|
-
const deferredIsTerminal =
|
|
2626
|
-
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2627
|
-
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2628
|
-
if (!hasCompleteCorrelation(deferredCorrelation)) {
|
|
2629
|
-
if (deferredIsTerminal)
|
|
2630
|
-
logDroppedPromptTerminal(
|
|
2720
|
+
if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
|
|
2721
|
+
try {
|
|
2722
|
+
const abortAcknowledgement = await record.adapter.cancel("turn");
|
|
2723
|
+
if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
|
|
2724
|
+
throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
|
|
2725
|
+
} catch (error) {
|
|
2726
|
+
await this.#failSession(
|
|
2631
2727
|
params.sessionId,
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2728
|
+
record.adapter,
|
|
2729
|
+
new AcpSdkAdapterError(
|
|
2730
|
+
"connection_closed",
|
|
2731
|
+
`ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
|
|
2732
|
+
),
|
|
2636
2733
|
);
|
|
2637
|
-
|
|
2734
|
+
throw error;
|
|
2735
|
+
}
|
|
2736
|
+
throw new AcpSdkAdapterError(
|
|
2737
|
+
"invalid_prompt_acknowledgement",
|
|
2738
|
+
"SDK prompt acknowledgement reused a settled commandId/turnId pair.",
|
|
2739
|
+
);
|
|
2638
2740
|
}
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
) {
|
|
2645
|
-
|
|
2646
|
-
|
|
2741
|
+
// Retain the acknowledgement ingress boundary with its complete correlation.
|
|
2742
|
+
waiter.boundary = record.inboundSequence;
|
|
2743
|
+
waiter.correlation = acknowledgementCorrelation;
|
|
2744
|
+
waiter.acknowledged = true;
|
|
2745
|
+
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2746
|
+
if (promptWaiterRetired(record, waiter)) {
|
|
2747
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2748
|
+
return await response;
|
|
2749
|
+
}
|
|
2750
|
+
if (waiter.cancelBeforeAdmission && record.cancelRequested) {
|
|
2751
|
+
const scope = waiter.cancelBeforeAdmissionScope ?? "turn";
|
|
2752
|
+
// Keep the intent until an acknowledged abort or the prompt terminal settles it.
|
|
2753
|
+
void this.cancel({
|
|
2754
|
+
sessionId: params.sessionId,
|
|
2755
|
+
_meta: { gjc: { abortScope: scope } },
|
|
2756
|
+
} as CancelNotification).catch(error =>
|
|
2757
|
+
logger.warn("ACP could not retry a cancel after prompt admission", {
|
|
2758
|
+
sessionId: params.sessionId,
|
|
2759
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2760
|
+
}),
|
|
2647
2761
|
);
|
|
2648
|
-
record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
|
|
2649
|
-
record.busy = true;
|
|
2650
|
-
continue;
|
|
2651
2762
|
}
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2763
|
+
// Frames held while ownership was unknown belong to this prompt only when the
|
|
2764
|
+
// acknowledgement proves their complete correlation matches exactly.
|
|
2765
|
+
const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
|
|
2766
|
+
let observedDeferredActivity = false;
|
|
2767
|
+
for (const deferredFrame of deferredActivityFrames) {
|
|
2768
|
+
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2769
|
+
const matchesWaiter = correlationsExactlyMatch(
|
|
2770
|
+
waiter.correlation,
|
|
2771
|
+
watchdogCorrelationFrom(deferredFrame, deferredEvent),
|
|
2772
|
+
);
|
|
2773
|
+
if (deferredEvent?.type === "agent_start" && !matchesWaiter) {
|
|
2774
|
+
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent);
|
|
2775
|
+
const tombstoned =
|
|
2776
|
+
deferredCorrelation &&
|
|
2777
|
+
record.settledPromptCorrelations.some(settled =>
|
|
2778
|
+
correlationsExactlyMatch(settled, deferredCorrelation),
|
|
2779
|
+
);
|
|
2780
|
+
if (!tombstoned) {
|
|
2781
|
+
record.backgroundBusy = true;
|
|
2782
|
+
if (deferredCorrelation && hasCompleteCorrelation(deferredCorrelation)) {
|
|
2783
|
+
if (
|
|
2784
|
+
!record.backgroundCorrelations.some(owner =>
|
|
2785
|
+
correlationsExactlyMatch(owner, deferredCorrelation),
|
|
2786
|
+
)
|
|
2787
|
+
)
|
|
2788
|
+
record.backgroundCorrelations.push(deferredCorrelation);
|
|
2789
|
+
} else record.backgroundAnonymousCount++;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
if (matchesWaiter) {
|
|
2793
|
+
this.#observePromptActivity(waiter, deferredFrame);
|
|
2794
|
+
if (waiter.observedTurnActivity) record.promptObservedActivity = true;
|
|
2795
|
+
if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
|
|
2796
|
+
observedDeferredActivity = true;
|
|
2797
|
+
}
|
|
2667
2798
|
}
|
|
2668
|
-
if (
|
|
2669
|
-
|
|
2670
|
-
|
|
2799
|
+
if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2800
|
+
const deferred = waiter.deferredFrames.splice(0);
|
|
2801
|
+
for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
|
|
2802
|
+
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2803
|
+
if (!deferredEvent) continue;
|
|
2804
|
+
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
|
|
2805
|
+
const deferredOutcome =
|
|
2806
|
+
deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
|
|
2807
|
+
? terminalOutcome(deferredEvent)
|
|
2808
|
+
: undefined;
|
|
2809
|
+
const deferredIsTerminal =
|
|
2810
|
+
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2811
|
+
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2812
|
+
if (!hasCompleteCorrelation(deferredCorrelation)) {
|
|
2813
|
+
if (deferredIsTerminal)
|
|
2814
|
+
logDroppedPromptTerminal(
|
|
2815
|
+
params.sessionId,
|
|
2816
|
+
deferredEvent,
|
|
2817
|
+
"incomplete_correlation",
|
|
2818
|
+
deferredCorrelation,
|
|
2819
|
+
waiter.correlation,
|
|
2820
|
+
);
|
|
2821
|
+
continue;
|
|
2822
|
+
}
|
|
2823
|
+
const matchesPrompt = correlationsExactlyMatch(waiter.correlation, deferredCorrelation);
|
|
2824
|
+
if (
|
|
2825
|
+
!matchesPrompt &&
|
|
2826
|
+
deferredIsTerminal &&
|
|
2827
|
+
record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, deferredCorrelation))
|
|
2828
|
+
) {
|
|
2829
|
+
record.backgroundCorrelations = record.backgroundCorrelations.filter(
|
|
2830
|
+
owner => !correlationsExactlyMatch(owner, deferredCorrelation),
|
|
2831
|
+
);
|
|
2832
|
+
record.backgroundBusy =
|
|
2833
|
+
record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
|
|
2834
|
+
record.busy = true;
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
if (
|
|
2838
|
+
!matchesPrompt &&
|
|
2839
|
+
record.settledPromptCorrelations.some(settled =>
|
|
2840
|
+
correlationsExactlyMatch(settled, deferredCorrelation),
|
|
2841
|
+
)
|
|
2842
|
+
)
|
|
2843
|
+
continue;
|
|
2844
|
+
if (!matchesPrompt) {
|
|
2845
|
+
if (deferredIsTerminal)
|
|
2846
|
+
logDroppedPromptTerminal(
|
|
2847
|
+
params.sessionId,
|
|
2848
|
+
deferredEvent,
|
|
2849
|
+
"correlation_mismatch",
|
|
2850
|
+
deferredCorrelation,
|
|
2851
|
+
waiter.correlation,
|
|
2852
|
+
);
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
if (deferredIsTerminal) {
|
|
2856
|
+
waiter.terminalReserved = true;
|
|
2857
|
+
clearPromptWatchdog(waiter);
|
|
2858
|
+
}
|
|
2859
|
+
const task = record.frameTail.then(
|
|
2860
|
+
async () =>
|
|
2861
|
+
await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
|
|
2862
|
+
);
|
|
2863
|
+
record.frameTail = task.catch(async error => {
|
|
2864
|
+
if (deferredGeneration !== record.publicationGeneration) return;
|
|
2865
|
+
if (record.activePrompt?.terminalReserved) return;
|
|
2866
|
+
await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error));
|
|
2867
|
+
});
|
|
2671
2868
|
}
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2869
|
+
this.#settlePrompt(params.sessionId, record, waiter);
|
|
2870
|
+
return await response;
|
|
2871
|
+
})()
|
|
2872
|
+
.catch(async error => {
|
|
2873
|
+
if (
|
|
2874
|
+
!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) ||
|
|
2875
|
+
error.code !== "uncertain_after_send"
|
|
2876
|
+
)
|
|
2877
|
+
throw error;
|
|
2878
|
+
if (record.adapter === promptAdapter)
|
|
2879
|
+
this.#startUncertainPromptRecovery(params.sessionId, record, waiter);
|
|
2880
|
+
return await response;
|
|
2881
|
+
})
|
|
2882
|
+
.finally(() => {
|
|
2883
|
+
waiter.acknowledgementPending = false;
|
|
2884
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2680
2885
|
});
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2886
|
+
// Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
|
|
2887
|
+
// must remain alive to tombstone its eventual correlation without holding the ACP caller.
|
|
2888
|
+
void acknowledgementTask.catch(() => undefined);
|
|
2889
|
+
try {
|
|
2890
|
+
return await Promise.race([response, acknowledgementTask]);
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
|
|
2893
|
+
waiter.deferredFrames.length = 0;
|
|
2894
|
+
waiter.deferredActivityFrames.length = 0;
|
|
2895
|
+
clearPromptWatchdog(waiter);
|
|
2896
|
+
waiter.terminal = undefined;
|
|
2897
|
+
waiter.settled = true;
|
|
2898
|
+
if (record.activePrompt === waiter) {
|
|
2899
|
+
record.activePrompt = undefined;
|
|
2900
|
+
record.busy = record.backgroundBusy;
|
|
2901
|
+
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2902
|
+
}
|
|
2903
|
+
// Keep a late terminal for this (cancelled) turn closed, exactly like the
|
|
2904
|
+
// other settlement paths, so an aborted run's trailing frame can never
|
|
2905
|
+
// publish over a later prompt that reuses the identity.
|
|
2906
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
|
|
2907
|
+
// A prompt cancelled before the SDK acknowledged it still ends this turn by
|
|
2908
|
+
// client request, and ACP is explicit: "Agents MUST catch these errors and
|
|
2909
|
+
// return the semantically meaningful `cancelled` stop reason, so that Clients
|
|
2910
|
+
// can reliably confirm the cancellation." Surfacing the transport's `busy`
|
|
2911
|
+
// rejection instead would show the user a spurious error for their own cancel.
|
|
2686
2912
|
if (
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
// must remain alive to tombstone its eventual correlation without holding the ACP caller.
|
|
2700
|
-
void acknowledgementTask.catch(() => undefined);
|
|
2701
|
-
try {
|
|
2702
|
-
return await Promise.race([response, acknowledgementTask]);
|
|
2703
|
-
} catch (error) {
|
|
2704
|
-
if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
|
|
2705
|
-
waiter.deferredFrames.length = 0;
|
|
2706
|
-
waiter.deferredActivityFrames.length = 0;
|
|
2707
|
-
clearPromptWatchdog(waiter);
|
|
2708
|
-
waiter.terminal = undefined;
|
|
2709
|
-
waiter.settled = true;
|
|
2710
|
-
if (record.activePrompt === waiter) {
|
|
2711
|
-
record.activePrompt = undefined;
|
|
2712
|
-
record.busy = record.backgroundBusy;
|
|
2713
|
-
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2714
|
-
}
|
|
2715
|
-
// Keep a late terminal for this (cancelled) turn closed, exactly like the
|
|
2716
|
-
// other settlement paths, so an aborted run's trailing frame can never
|
|
2717
|
-
// publish over a later prompt that reuses the identity.
|
|
2718
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
|
|
2719
|
-
// A prompt cancelled before the SDK acknowledged it still ends this turn by
|
|
2720
|
-
// client request, and ACP is explicit: "Agents MUST catch these errors and
|
|
2721
|
-
// return the semantically meaningful `cancelled` stop reason, so that Clients
|
|
2722
|
-
// can reliably confirm the cancellation." Surfacing the transport's `busy`
|
|
2723
|
-
// rejection instead would show the user a spurious error for their own cancel.
|
|
2724
|
-
if (
|
|
2725
|
-
record.cancelRequested &&
|
|
2726
|
-
(waiter.cancelAcknowledged || (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
|
|
2727
|
-
) {
|
|
2728
|
-
record.cancelRequested = false;
|
|
2729
|
-
// The client's turn is settled by the return; the advisory idle publication
|
|
2730
|
-
// must not gate it and must still be attempted so the running phase is
|
|
2731
|
-
// released (gjcRunning:false) instead of spinning behind a settled cancel.
|
|
2732
|
-
return { stopReason: "cancelled" };
|
|
2913
|
+
record.cancelRequested &&
|
|
2914
|
+
(waiter.cancelAcknowledged ||
|
|
2915
|
+
(waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
|
|
2916
|
+
) {
|
|
2917
|
+
record.cancelRequested = false;
|
|
2918
|
+
waiter.resolve({ stopReason: "cancelled" });
|
|
2919
|
+
// The client's turn is settled by the return; the advisory idle publication
|
|
2920
|
+
// must not gate it and must still be attempted so the running phase is
|
|
2921
|
+
// released (gjcRunning:false) instead of spinning behind a settled cancel.
|
|
2922
|
+
return { stopReason: "cancelled" };
|
|
2923
|
+
}
|
|
2924
|
+
throw error;
|
|
2733
2925
|
}
|
|
2734
|
-
|
|
2926
|
+
} finally {
|
|
2927
|
+
// Exception exits can release ownership without settling the public response.
|
|
2928
|
+
// Wake successor admission without replacing the predecessor's original result.
|
|
2929
|
+
completeOwnership();
|
|
2735
2930
|
}
|
|
2736
2931
|
}
|
|
2737
2932
|
|
|
@@ -2741,6 +2936,7 @@ export class AcpAgent implements Agent {
|
|
|
2741
2936
|
// Record the client's intent before awaiting the SDK so a prompt that rejects
|
|
2742
2937
|
// mid-cancel (e.g. preflight `busy`) can still settle as `cancelled`.
|
|
2743
2938
|
record.cancelRequested = true;
|
|
2939
|
+
this.#settlePendingPromptAdmission(record, { kind: "cancelled" });
|
|
2744
2940
|
// C04 terminal abort: an external client cancel stops the current turn
|
|
2745
2941
|
// (`scope:"turn"`, the default, matching the SDK `turn.abort` default and
|
|
2746
2942
|
// other ACP clients' cancel behavior). A client that also wants exact owned
|
|
@@ -2911,6 +3107,16 @@ export class AcpAgent implements Agent {
|
|
|
2911
3107
|
}
|
|
2912
3108
|
}
|
|
2913
3109
|
|
|
3110
|
+
#settlePendingPromptAdmission(record: SessionRecord, settlement: PromptAdmissionSettlement): void {
|
|
3111
|
+
const reservation = record.pendingPromptAdmission;
|
|
3112
|
+
if (!reservation) return;
|
|
3113
|
+
reservation.settlement ??= settlement;
|
|
3114
|
+
if (settlement.kind === "cancelled") reservation.cancelled = true;
|
|
3115
|
+
reservation.wake();
|
|
3116
|
+
for (const resolve of record.idleWaiters) resolve();
|
|
3117
|
+
record.idleWaiters.clear();
|
|
3118
|
+
}
|
|
3119
|
+
|
|
2914
3120
|
/**
|
|
2915
3121
|
* `aborted: true` means the run is gone, so the pending prompt is already over even
|
|
2916
3122
|
* if no normalized terminal follows. Without this the waiter stays pending forever:
|
|
@@ -3216,6 +3422,7 @@ export class AcpAgent implements Agent {
|
|
|
3216
3422
|
this.#pendingRouterFrames.set(id, bufferedFrames);
|
|
3217
3423
|
try {
|
|
3218
3424
|
await this.#ensureRouterReady();
|
|
3425
|
+
await this.#router.reconcile({ waitForReplay: false });
|
|
3219
3426
|
if (lifecycleResult) await this.#router.adoptLifecycleResult(lifecycleResult, { sessionId: id, cwd });
|
|
3220
3427
|
let currentAttachment = this.#router.attachment(id);
|
|
3221
3428
|
for (let attempt = 0; !currentAttachment && attempt < 40; attempt++) {
|
|
@@ -3283,6 +3490,8 @@ export class AcpAgent implements Agent {
|
|
|
3283
3490
|
backgroundBusy: retainedAbortOwner !== undefined,
|
|
3284
3491
|
backgroundAnonymousCount: 0,
|
|
3285
3492
|
backgroundCorrelations: retainedAbortOwner?.correlation ? [retainedAbortOwner.correlation] : [],
|
|
3493
|
+
sdkIdle: retainedAbortOwner === undefined,
|
|
3494
|
+
idleWaiters: new Set(),
|
|
3286
3495
|
toolArgs: new Map(),
|
|
3287
3496
|
uncertainAbortOwner: retainedAbortOwner,
|
|
3288
3497
|
};
|
|
@@ -3973,6 +4182,17 @@ export class AcpAgent implements Agent {
|
|
|
3973
4182
|
};
|
|
3974
4183
|
record.pendingFirstPromptRetry = undefined;
|
|
3975
4184
|
}
|
|
4185
|
+
const admissionReservation = record.pendingPromptAdmission;
|
|
4186
|
+
if (admissionReservation) {
|
|
4187
|
+
admissionReservation.settlement ??= voluntary
|
|
4188
|
+
? { kind: "cancelled" }
|
|
4189
|
+
: {
|
|
4190
|
+
kind: "rejected",
|
|
4191
|
+
error: new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`),
|
|
4192
|
+
};
|
|
4193
|
+
admissionReservation.wake();
|
|
4194
|
+
record.pendingPromptAdmission = undefined;
|
|
4195
|
+
}
|
|
3976
4196
|
}
|
|
3977
4197
|
|
|
3978
4198
|
const failures: unknown[] = [];
|
|
@@ -4026,6 +4246,11 @@ export class AcpAgent implements Agent {
|
|
|
4026
4246
|
this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
|
|
4027
4247
|
this.#fenceRetiredPromptAcknowledgement(id, waiter);
|
|
4028
4248
|
}
|
|
4249
|
+
if (record.pendingPromptAdmission) {
|
|
4250
|
+
record.pendingPromptAdmission.settlement = { kind: "rejected", error };
|
|
4251
|
+
record.pendingPromptAdmission.wake();
|
|
4252
|
+
record.pendingPromptAdmission = undefined;
|
|
4253
|
+
}
|
|
4029
4254
|
waiter?.reject(error);
|
|
4030
4255
|
try {
|
|
4031
4256
|
await adapter.close();
|
|
@@ -4095,12 +4320,14 @@ export class AcpAgent implements Agent {
|
|
|
4095
4320
|
#observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
|
|
4096
4321
|
if (frame.type === "activity") {
|
|
4097
4322
|
if (frame.state === "busy") {
|
|
4323
|
+
record.sdkIdle = false;
|
|
4098
4324
|
record.busy = true;
|
|
4099
4325
|
if (!record.activePrompt) {
|
|
4100
4326
|
record.backgroundBusy = true;
|
|
4101
4327
|
record.backgroundAnonymousCount = Math.max(record.backgroundAnonymousCount, 1);
|
|
4102
4328
|
}
|
|
4103
4329
|
} else if (frame.state === "idle") {
|
|
4330
|
+
record.sdkIdle = true;
|
|
4104
4331
|
if (record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0) {
|
|
4105
4332
|
record.busy = true;
|
|
4106
4333
|
record.backgroundBusy = true;
|
|
@@ -4110,6 +4337,8 @@ export class AcpAgent implements Agent {
|
|
|
4110
4337
|
record.backgroundBusy = false;
|
|
4111
4338
|
record.backgroundAnonymousCount = 0;
|
|
4112
4339
|
record.backgroundCorrelations.length = 0;
|
|
4340
|
+
for (const resolve of record.idleWaiters) resolve();
|
|
4341
|
+
record.idleWaiters.clear();
|
|
4113
4342
|
}
|
|
4114
4343
|
return;
|
|
4115
4344
|
}
|
|
@@ -4143,6 +4372,20 @@ export class AcpAgent implements Agent {
|
|
|
4143
4372
|
}
|
|
4144
4373
|
}
|
|
4145
4374
|
|
|
4375
|
+
/** Wait for the SDK's winding-down turn to publish idle before retrying a fresh prompt. */
|
|
4376
|
+
#waitForSessionIdle(record: SessionRecord): Promise<boolean> {
|
|
4377
|
+
if (record.sdkIdle && !record.backgroundBusy) return Promise.resolve(true);
|
|
4378
|
+
const deferred = Promise.withResolvers<void>();
|
|
4379
|
+
record.idleWaiters.add(deferred.resolve);
|
|
4380
|
+
const cancel = this.#promptWatchdogClock.schedule(deferred.resolve, ACP_BUSY_SETTLE_WAIT_MS);
|
|
4381
|
+
return deferred.promise
|
|
4382
|
+
.then(() => record.sdkIdle && !record.backgroundBusy)
|
|
4383
|
+
.finally(() => {
|
|
4384
|
+
record.idleWaiters.delete(deferred.resolve);
|
|
4385
|
+
cancel();
|
|
4386
|
+
});
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4146
4389
|
#frameProcessingFailure(error: unknown): AcpSdkAdapterError {
|
|
4147
4390
|
if (error instanceof AcpSdkAdapterError && error.code === "frame_processing_failed") return error;
|
|
4148
4391
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -4205,6 +4448,7 @@ export class AcpAgent implements Agent {
|
|
|
4205
4448
|
typeof event?.type === "string" ? event.type : undefined,
|
|
4206
4449
|
typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
|
|
4207
4450
|
frameMessageRole(event),
|
|
4451
|
+
frameMessageHasToolCall(event),
|
|
4208
4452
|
);
|
|
4209
4453
|
}
|
|
4210
4454
|
|
|
@@ -4966,10 +5210,11 @@ export class AcpAgent implements Agent {
|
|
|
4966
5210
|
// connection watched start. Upgrade it from the frames this prompt actually owned, never
|
|
4967
5211
|
// downgrade a terminal that already reported `post_start`, and carry the whole
|
|
4968
5212
|
// classification on the rejection so the first-turn retry gate can read it (review P1).
|
|
4969
|
-
const failure =
|
|
5213
|
+
const failure = (
|
|
4970
5214
|
outcome.phase === "post_start"
|
|
4971
5215
|
? outcome
|
|
4972
|
-
:
|
|
5216
|
+
: rephaseFailedOutcome(outcome, { hasActivity: waiter.observedTurnActivity })
|
|
5217
|
+
) as SdkPromptFailedOutcome;
|
|
4973
5218
|
waiter.reject(new AcpPromptFailureError(failure));
|
|
4974
5219
|
}
|
|
4975
5220
|
|
|
@@ -5093,6 +5338,78 @@ export class AcpAgent implements Agent {
|
|
|
5093
5338
|
}
|
|
5094
5339
|
}
|
|
5095
5340
|
|
|
5341
|
+
/**
|
|
5342
|
+
* Wait for the model to settle when no explicit --model was passed but a default might be configured.
|
|
5343
|
+
* Give each probe its own timeout clamped to the remaining budget so wedged probes cannot exceed 500ms total.
|
|
5344
|
+
* Catch probe errors as advisory so a rejected probe does not discard the session (issue #6009).
|
|
5345
|
+
*/
|
|
5346
|
+
async #waitForModelSettle(id: string): Promise<void> {
|
|
5347
|
+
const record = this.#sessions.get(id);
|
|
5348
|
+
if (!record) return; // Session was closed; skip settlement wait.
|
|
5349
|
+
|
|
5350
|
+
// If an explicit --model was passed, it's already settled by applyAcpStartupOptions.
|
|
5351
|
+
if (this.#startupOptions?.modelId) return;
|
|
5352
|
+
|
|
5353
|
+
// Set the deadline once for all probes (including the first).
|
|
5354
|
+
const deadline = Date.now() + ACP_MODEL_SETTLEMENT_TIMEOUT_MS;
|
|
5355
|
+
|
|
5356
|
+
// Query the config once to check if the model is already settled.
|
|
5357
|
+
// Even the first probe gets its own timeout clamped to the remaining budget.
|
|
5358
|
+
let config: unknown;
|
|
5359
|
+
const remaining = deadline - Date.now();
|
|
5360
|
+
if (remaining > 0) {
|
|
5361
|
+
try {
|
|
5362
|
+
const controller = new AbortController();
|
|
5363
|
+
const timeoutHandle = setTimeout(() => controller.abort(), remaining);
|
|
5364
|
+
try {
|
|
5365
|
+
const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
|
|
5366
|
+
const listener = () => rejectTimeout(new Error("probe_timeout"));
|
|
5367
|
+
controller.signal.addEventListener("abort", listener);
|
|
5368
|
+
config = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
|
|
5369
|
+
} finally {
|
|
5370
|
+
clearTimeout(timeoutHandle);
|
|
5371
|
+
}
|
|
5372
|
+
} catch {
|
|
5373
|
+
// Advisory: ignore first probe error and proceed to retry loop.
|
|
5374
|
+
// The first probe's failure doesn't mean the model won't settle later.
|
|
5375
|
+
config = undefined;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
const currentModel = config !== undefined ? configValues(config).get(MODEL_CONFIG_ID) : undefined;
|
|
5379
|
+
if (currentModel !== undefined) return; // Model is settled.
|
|
5380
|
+
if (remaining <= 0) return; // Budget exhausted before entering retry loop.
|
|
5381
|
+
|
|
5382
|
+
// No explicit model and no settled value: the SDK may be applying a default.
|
|
5383
|
+
// Retry with a bounded timeout, where each probe gets its own timeout clamped to the remaining budget.
|
|
5384
|
+
// Only wait if there's a reasonable chance a default is configured (no model after first probe).
|
|
5385
|
+
while (Date.now() < deadline) {
|
|
5386
|
+
await Bun.sleep(50); // Small delay before retry.
|
|
5387
|
+
const remaining = deadline - Date.now();
|
|
5388
|
+
if (remaining <= 0) break; // Budget exhausted.
|
|
5389
|
+
|
|
5390
|
+
let nextConfig: unknown;
|
|
5391
|
+
try {
|
|
5392
|
+
// Give this probe its own timeout clamped to the remaining budget.
|
|
5393
|
+
// A wedged probe will timeout after `remaining` ms, not the full SDK timeout.
|
|
5394
|
+
const controller = new AbortController();
|
|
5395
|
+
const timeoutHandle = setTimeout(() => controller.abort(), remaining);
|
|
5396
|
+
try {
|
|
5397
|
+
const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
|
|
5398
|
+
const listener = () => rejectTimeout(new Error("probe_timeout"));
|
|
5399
|
+
controller.signal.addEventListener("abort", listener);
|
|
5400
|
+
nextConfig = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
|
|
5401
|
+
} finally {
|
|
5402
|
+
clearTimeout(timeoutHandle);
|
|
5403
|
+
}
|
|
5404
|
+
} catch {
|
|
5405
|
+
// Advisory: probe failed or timed out; continue retrying while budget remains.
|
|
5406
|
+
continue;
|
|
5407
|
+
}
|
|
5408
|
+
if (configValues(nextConfig).get(MODEL_CONFIG_ID) !== undefined) return; // Settled.
|
|
5409
|
+
}
|
|
5410
|
+
// Budget exhausted: proceed without model. The model option may be missing from the response.
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5096
5413
|
async #sessionState(
|
|
5097
5414
|
id: string,
|
|
5098
5415
|
rejectUnavailableStartupPreset = false,
|