@gajae-code/coding-agent 0.18.5 → 0.18.7
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 +140 -0
- package/dist/types/capability/types.d.ts +4 -0
- 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/model-profile-activation.d.ts +19 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- 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 +9 -0
- package/dist/types/sdk/broker/runtime.d.ts +1 -1
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
- 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/host.d.ts +6 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
- package/dist/types/session/session-retirement.d.ts +47 -0
- package/dist/types/session/session-storage.d.ts +0 -46
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -1
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/scripts/resident-memory-bench.ts +174 -20
- package/src/capability/index.ts +1 -0
- package/src/capability/types.ts +4 -0
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +7 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -182
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +171 -21
- package/src/commands/update.ts +15 -1
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-profile-activation.ts +70 -4
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +23 -31
- package/src/config/model-resolver.ts +9 -1
- package/src/config/settings.ts +44 -4
- 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/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/gjc-runtime/gc-runtime.ts +2 -1
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +724 -370
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +54 -1
- 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 +334 -348
- package/src/sdk/broker/runtime.ts +5 -1
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +3 -3
- 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 +38 -3
- 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/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +83 -4
- package/src/sdk/host/session-runtime.ts +15 -7
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +26 -3
- package/src/sdk/session.ts +51 -8
- package/src/session/agent-session.ts +169 -43
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +203 -4
- package/src/session/internal/task-artifact-owner-access.ts +229 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
- package/src/session/session-retirement.ts +155 -0
- package/src/session/session-storage.ts +1 -160
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +578 -0
- package/src/session/task-artifact-owner-retirement.ts +475 -0
- package/src/session/task-artifact-owner.ts +289 -0
- 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 +38 -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
|
@@ -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
|
/**
|
|
@@ -1404,6 +1426,21 @@ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhi
|
|
|
1404
1426
|
name: value,
|
|
1405
1427
|
}));
|
|
1406
1428
|
|
|
1429
|
+
function thinkingConfigOptions(query: unknown, currentModel: string | undefined): { value: string; name: string }[] {
|
|
1430
|
+
if (!currentModel) return THINKING_CONFIG_OPTIONS;
|
|
1431
|
+
const separator = currentModel.indexOf("/");
|
|
1432
|
+
if (separator <= 0 || separator === currentModel.length - 1) return THINKING_CONFIG_OPTIONS;
|
|
1433
|
+
const provider = currentModel.slice(0, separator);
|
|
1434
|
+
const id = currentModel.slice(separator + 1);
|
|
1435
|
+
const model = pageItems(query)
|
|
1436
|
+
.map(item => object(item))
|
|
1437
|
+
.find(item => item?.provider === provider && item.id === id);
|
|
1438
|
+
const validLevels = object(model?.thinking)?.validLevels;
|
|
1439
|
+
if (!Array.isArray(validLevels) || validLevels.length === 0) return THINKING_CONFIG_OPTIONS;
|
|
1440
|
+
const levels = validLevels.filter((value): value is string => typeof value === "string");
|
|
1441
|
+
return levels.length === 0 ? THINKING_CONFIG_OPTIONS : levels.map(value => ({ value, name: value }));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1407
1444
|
/** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
|
|
1408
1445
|
export function acpSessionStateFromConfig(
|
|
1409
1446
|
query: unknown,
|
|
@@ -1414,6 +1451,7 @@ export function acpSessionStateFromConfig(
|
|
|
1414
1451
|
const values = configValues(query);
|
|
1415
1452
|
const useModelPresets = modelPreset !== undefined;
|
|
1416
1453
|
const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
|
|
1454
|
+
const thinkingOptions = thinkingConfigOptions(modelCatalogQuery, values.get(MODEL_CONFIG_ID));
|
|
1417
1455
|
return {
|
|
1418
1456
|
configOptions: [
|
|
1419
1457
|
{
|
|
@@ -1439,7 +1477,7 @@ export function acpSessionStateFromConfig(
|
|
|
1439
1477
|
? modelPresetConfigOptions(modelCatalogQuery, value)
|
|
1440
1478
|
: modelConfigOptions(modelCatalogQuery, value, activeProviders)
|
|
1441
1479
|
: option.id === THINKING_CONFIG_ID
|
|
1442
|
-
?
|
|
1480
|
+
? thinkingOptions
|
|
1443
1481
|
: [...option.options];
|
|
1444
1482
|
return [
|
|
1445
1483
|
{
|
|
@@ -1749,6 +1787,10 @@ export class AcpAgent implements Agent {
|
|
|
1749
1787
|
this.#agentDir = typeof candidate?.agentDir === "string" ? candidate.agentDir : getAgentDir();
|
|
1750
1788
|
this.#router = new SessionRouter({
|
|
1751
1789
|
agentDir: this.#agentDir,
|
|
1790
|
+
attachFilter: sessionId =>
|
|
1791
|
+
this.#sessions.has(sessionId) ||
|
|
1792
|
+
this.#pendingRouterAdapters.has(sessionId) ||
|
|
1793
|
+
this.#pendingRouterFrames.has(sessionId),
|
|
1752
1794
|
deps: {
|
|
1753
1795
|
onAttachment: attachment => {
|
|
1754
1796
|
const record = this.#sessions.get(attachment.sessionId);
|
|
@@ -1772,11 +1814,13 @@ export class AcpAgent implements Agent {
|
|
|
1772
1814
|
if (adapter) adapter.acceptFrame(acpFrame);
|
|
1773
1815
|
else this.#pendingRouterFrames.get(attachment.sessionId)?.push(acpFrame);
|
|
1774
1816
|
},
|
|
1775
|
-
onSessionRemoved: attachment => {
|
|
1817
|
+
onSessionRemoved: (attachment, removalReason) => {
|
|
1776
1818
|
const adapter =
|
|
1777
1819
|
this.#sessions.get(attachment.sessionId)?.adapter ??
|
|
1778
1820
|
this.#pendingRouterAdapters.get(attachment.sessionId);
|
|
1779
1821
|
adapter?.revokeAttachment(attachment);
|
|
1822
|
+
if (adapter && removalReason !== "replaced_same_generation")
|
|
1823
|
+
this.#settlePromptAfterHostClose(attachment.sessionId, removalReason ?? "host_exit");
|
|
1780
1824
|
},
|
|
1781
1825
|
},
|
|
1782
1826
|
});
|
|
@@ -1848,33 +1892,62 @@ export class AcpAgent implements Agent {
|
|
|
1848
1892
|
const mcpServers = this.#mcpServers(params);
|
|
1849
1893
|
this.#assertAbsoluteCwd(params.cwd);
|
|
1850
1894
|
this.#assertNoAdditionalDirectories(params.additionalDirectories);
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
cwd: params.cwd,
|
|
1855
|
-
target: { path: params.cwd },
|
|
1856
|
-
...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
|
|
1857
|
-
readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
|
|
1858
|
-
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
1859
|
-
},
|
|
1860
|
-
randomUUID(),
|
|
1861
|
-
mcpServers,
|
|
1862
|
-
);
|
|
1863
|
-
const id = sessionId(result);
|
|
1864
|
-
this.#knownSessionCwds.set(id, params.cwd);
|
|
1865
|
-
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1866
|
-
// This connection launched the host, so it owns the broker lifecycle before the
|
|
1867
|
-
// attachment reports a control surface. Without it a failed attach discards the
|
|
1868
|
-
// session as unowned and masks the real error with cleanup uncertainty.
|
|
1869
|
-
this.#ownedSessionIds.add(id);
|
|
1895
|
+
const startedAt = performance.now();
|
|
1896
|
+
const phases: Record<string, number> = {};
|
|
1897
|
+
let id: string | undefined;
|
|
1870
1898
|
try {
|
|
1899
|
+
let phaseStartedAt = performance.now();
|
|
1900
|
+
const result = await this.#launchSessionWithMcp(
|
|
1901
|
+
"session.create",
|
|
1902
|
+
{
|
|
1903
|
+
cwd: params.cwd,
|
|
1904
|
+
target: { path: params.cwd },
|
|
1905
|
+
...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
|
|
1906
|
+
readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
|
|
1907
|
+
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
1908
|
+
},
|
|
1909
|
+
randomUUID(),
|
|
1910
|
+
mcpServers,
|
|
1911
|
+
);
|
|
1912
|
+
phases.launchMs = performance.now() - phaseStartedAt;
|
|
1913
|
+
id = sessionId(result);
|
|
1914
|
+
this.#knownSessionCwds.set(id, params.cwd);
|
|
1915
|
+
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1916
|
+
// This connection launched the host, so it owns the broker lifecycle before the
|
|
1917
|
+
// attachment reports a control surface. Without it a failed attach discards the
|
|
1918
|
+
// session as unowned and masks the real error with cleanup uncertainty.
|
|
1919
|
+
this.#ownedSessionIds.add(id);
|
|
1920
|
+
phaseStartedAt = performance.now();
|
|
1871
1921
|
await this.#attach(id, params.cwd, undefined, result);
|
|
1922
|
+
phases.attachMs = performance.now() - phaseStartedAt;
|
|
1923
|
+
phaseStartedAt = performance.now();
|
|
1872
1924
|
await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
|
|
1925
|
+
phases.startupOptionsMs = performance.now() - phaseStartedAt;
|
|
1926
|
+
phaseStartedAt = performance.now();
|
|
1927
|
+
await this.#waitForModelSettle(id);
|
|
1928
|
+
phases.modelSettleMs = performance.now() - phaseStartedAt;
|
|
1929
|
+
phaseStartedAt = performance.now();
|
|
1873
1930
|
const response = { sessionId: id, ...(await this.#sessionState(id, true)) };
|
|
1931
|
+
phases.sessionStateMs = performance.now() - phaseStartedAt;
|
|
1874
1932
|
this.#scheduleBootstrap(id);
|
|
1933
|
+
logger.debug("acp_session_new_phases", {
|
|
1934
|
+
sessionId: id,
|
|
1935
|
+
...phases,
|
|
1936
|
+
outcome: "ok",
|
|
1937
|
+
totalMs: performance.now() - startedAt,
|
|
1938
|
+
});
|
|
1875
1939
|
return response;
|
|
1876
1940
|
} catch (error) {
|
|
1877
|
-
|
|
1941
|
+
try {
|
|
1942
|
+
if (id !== undefined) await this.#discardNewSession(id);
|
|
1943
|
+
} finally {
|
|
1944
|
+
logger.debug("acp_session_new_phases", {
|
|
1945
|
+
...(id !== undefined ? { sessionId: id } : {}),
|
|
1946
|
+
...phases,
|
|
1947
|
+
outcome: "error",
|
|
1948
|
+
totalMs: performance.now() - startedAt,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1878
1951
|
throw error;
|
|
1879
1952
|
}
|
|
1880
1953
|
}
|
|
@@ -2103,7 +2176,22 @@ export class AcpAgent implements Agent {
|
|
|
2103
2176
|
}
|
|
2104
2177
|
break;
|
|
2105
2178
|
case THINKING_CONFIG_ID:
|
|
2106
|
-
|
|
2179
|
+
{
|
|
2180
|
+
const before = await this.#sessionState(params.sessionId);
|
|
2181
|
+
const thinkingOption = before.configOptions?.find(option => option.id === THINKING_CONFIG_ID);
|
|
2182
|
+
if (
|
|
2183
|
+
thinkingOption?.type !== "select" ||
|
|
2184
|
+
!thinkingOption.options.some(option => "value" in option && option.value === params.value)
|
|
2185
|
+
)
|
|
2186
|
+
throw new Error(`Unsupported thinking level: ${params.value}`);
|
|
2187
|
+
await this.#adapter(params.sessionId).control("thinking.set", { level: params.value });
|
|
2188
|
+
const after = await this.#sessionState(params.sessionId);
|
|
2189
|
+
const currentValue = after.configOptions?.find(option => option.id === THINKING_CONFIG_ID)?.currentValue;
|
|
2190
|
+
if (currentValue !== params.value)
|
|
2191
|
+
throw new Error(
|
|
2192
|
+
`Thinking level was not applied: requested ${params.value}, current ${currentValue ?? "unknown"}`,
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2107
2195
|
break;
|
|
2108
2196
|
default: {
|
|
2109
2197
|
const operation = ACP_CONFIG_CONTROL_OPERATIONS[params.configId];
|
|
@@ -2277,6 +2365,70 @@ export class AcpAgent implements Agent {
|
|
|
2277
2365
|
): Promise<PromptResponse> {
|
|
2278
2366
|
const record = this.#sessions.get(params.sessionId);
|
|
2279
2367
|
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
|
|
2368
|
+
let admissionReservation: PromptAdmissionReservation | undefined;
|
|
2369
|
+
if (record.pendingPromptAdmission)
|
|
2370
|
+
throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2371
|
+
const activePrompt = record.activePrompt;
|
|
2372
|
+
if (activePrompt) {
|
|
2373
|
+
const cancellationPending =
|
|
2374
|
+
record.cancelRequested ||
|
|
2375
|
+
activePrompt.cancelAttempt !== undefined ||
|
|
2376
|
+
activePrompt.cancelAcknowledged === true;
|
|
2377
|
+
if (!cancellationPending || record.pendingPromptAdmission)
|
|
2378
|
+
throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2379
|
+
const { promise: woken, resolve: wake } = Promise.withResolvers<void>();
|
|
2380
|
+
admissionReservation = { wake, woken };
|
|
2381
|
+
record.pendingPromptAdmission = admissionReservation;
|
|
2382
|
+
}
|
|
2383
|
+
try {
|
|
2384
|
+
if (admissionReservation) {
|
|
2385
|
+
const settled = await this.#waitForPromptSettlement(activePrompt as PromptWaiter, admissionReservation);
|
|
2386
|
+
const settlement = admissionReservation.settlement;
|
|
2387
|
+
if (settlement?.kind === "rejected") throw settlement.error;
|
|
2388
|
+
if (settlement?.kind === "cancelled" || admissionReservation.cancelled) return { stopReason: "cancelled" };
|
|
2389
|
+
if (!settled) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
2390
|
+
await this.#drainPromptPublicationTails(params.sessionId);
|
|
2391
|
+
if (admissionReservation.settlement?.kind === "rejected") throw admissionReservation.settlement.error;
|
|
2392
|
+
if (admissionReservation.settlement?.kind === "cancelled" || admissionReservation.cancelled)
|
|
2393
|
+
return { stopReason: "cancelled" };
|
|
2394
|
+
}
|
|
2395
|
+
return await this.#submitPromptCore(params, echoUserMessage, retryReservation, admissionReservation);
|
|
2396
|
+
} finally {
|
|
2397
|
+
if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
async #waitForPromptSettlement(
|
|
2402
|
+
waiter: PromptWaiter,
|
|
2403
|
+
admissionReservation: PromptAdmissionReservation,
|
|
2404
|
+
): Promise<boolean> {
|
|
2405
|
+
let cancelTimer: (() => void) | undefined;
|
|
2406
|
+
const timeout = new Promise<boolean>(resolve => {
|
|
2407
|
+
cancelTimer = this.#promptWatchdogClock.schedule(() => resolve(false), ACP_BUSY_SETTLE_WAIT_MS);
|
|
2408
|
+
});
|
|
2409
|
+
try {
|
|
2410
|
+
return await Promise.race([
|
|
2411
|
+
waiter.settlement.then(() => true),
|
|
2412
|
+
timeout,
|
|
2413
|
+
admissionReservation.woken.then(() => true),
|
|
2414
|
+
]);
|
|
2415
|
+
} finally {
|
|
2416
|
+
cancelTimer?.();
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
async #submitPromptCore(
|
|
2421
|
+
params: PromptRequest,
|
|
2422
|
+
echoUserMessage: boolean,
|
|
2423
|
+
retryReservation?: FirstPromptRetryReservation,
|
|
2424
|
+
admissionReservation?: PromptAdmissionReservation,
|
|
2425
|
+
): Promise<PromptResponse> {
|
|
2426
|
+
const admissionSettlement = admissionReservation?.settlement;
|
|
2427
|
+
if (admissionSettlement?.kind === "rejected") throw admissionSettlement.error;
|
|
2428
|
+
if (admissionSettlement?.kind === "cancelled" || admissionReservation?.cancelled)
|
|
2429
|
+
return { stopReason: "cancelled" };
|
|
2430
|
+
const record = this.#sessions.get(params.sessionId);
|
|
2431
|
+
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
|
|
2280
2432
|
// A first-turn retry holds the session across its backoff gap. Any prompt that is not the
|
|
2281
2433
|
// retry owner is rejected here, before any state mutation or dispatch, so it cannot steal
|
|
2282
2434
|
// the first turn while the authorized retry is still pending (review P1, issue #5574).
|
|
@@ -2362,6 +2514,11 @@ export class AcpAgent implements Agent {
|
|
|
2362
2514
|
record.promptObservedToolExecution = false;
|
|
2363
2515
|
record.promptObservedAssistantOutput = false;
|
|
2364
2516
|
const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
|
|
2517
|
+
const { promise: settlement, resolve: completeOwnership } = Promise.withResolvers<void>();
|
|
2518
|
+
void response.then(
|
|
2519
|
+
() => completeOwnership(),
|
|
2520
|
+
() => completeOwnership(),
|
|
2521
|
+
);
|
|
2365
2522
|
const waiter: PromptWaiter = {
|
|
2366
2523
|
invocationKind: skillInvocation ? "skill" : "prompt",
|
|
2367
2524
|
clientRef,
|
|
@@ -2382,31 +2539,53 @@ export class AcpAgent implements Agent {
|
|
|
2382
2539
|
activity: new PromptActivity(),
|
|
2383
2540
|
observedTurnActivity: false,
|
|
2384
2541
|
observedToolExecution: false,
|
|
2542
|
+
settlement,
|
|
2385
2543
|
resolve,
|
|
2386
2544
|
reject,
|
|
2387
2545
|
};
|
|
2388
|
-
record.activePrompt = waiter;
|
|
2389
|
-
// The watchdog may reject this waiter while the SDK acknowledgement request is still
|
|
2390
|
-
// pending. Retain the original promise for the caller, but mark that delayed rejection
|
|
2391
|
-
// as observed until prompt() can resume and await it.
|
|
2392
|
-
void response.catch(() => undefined);
|
|
2393
|
-
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
2394
|
-
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
2395
|
-
// Provider preflight is part of that window — it awaits a lease the host may never win
|
|
2396
|
-
// — so the bound is armed before it, not after (issue #5658). The arm below re-arms
|
|
2397
|
-
// this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
|
|
2398
|
-
// timer first, so only one timer is ever live.
|
|
2399
|
-
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2400
|
-
// The watchdog settles `response`, not this await, so a preflight that never returns
|
|
2401
|
-
// would pin session/prompt open with the caller's promise already rejected. Racing the
|
|
2402
|
-
// settlement lets the re-checks below hand that rejection back to the caller.
|
|
2403
|
-
const settlement = response.then(
|
|
2404
|
-
() => undefined,
|
|
2405
|
-
() => undefined,
|
|
2406
|
-
);
|
|
2407
2546
|
try {
|
|
2408
|
-
|
|
2409
|
-
|
|
2547
|
+
record.activePrompt = waiter;
|
|
2548
|
+
if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
|
|
2549
|
+
// The watchdog may reject this waiter while the SDK acknowledgement request is still
|
|
2550
|
+
// pending. Retain the original promise for the caller, but mark that delayed rejection
|
|
2551
|
+
// as observed until prompt() can resume and await it.
|
|
2552
|
+
void response.catch(() => undefined);
|
|
2553
|
+
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
2554
|
+
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
2555
|
+
// Provider preflight is part of that window — it awaits a lease the host may never win
|
|
2556
|
+
// — so the bound is armed before it, not after (issue #5658). The arm below re-arms
|
|
2557
|
+
// this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
|
|
2558
|
+
// timer first, so only one timer is ever live.
|
|
2559
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2560
|
+
// The watchdog settles `response`, not this await, so a preflight that never returns
|
|
2561
|
+
// would pin session/prompt open with the caller's promise already rejected. Racing the
|
|
2562
|
+
// settlement lets the re-checks below hand that rejection back to the caller.
|
|
2563
|
+
try {
|
|
2564
|
+
await Promise.race([record.adapter.ensureProviders(), settlement]);
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
waiter.awaitingProviderPreflight = false;
|
|
2567
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2568
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2569
|
+
return await response;
|
|
2570
|
+
}
|
|
2571
|
+
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2572
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2573
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2574
|
+
return await response;
|
|
2575
|
+
}
|
|
2576
|
+
if (record.activePrompt === waiter) {
|
|
2577
|
+
record.activePrompt = undefined;
|
|
2578
|
+
record.busy = record.backgroundBusy;
|
|
2579
|
+
clearPromptWatchdog(waiter);
|
|
2580
|
+
// #5526's guarded release replaces the unguarded delete: it only clears
|
|
2581
|
+
// the entry when it still belongs to THIS waiter, so a successor's
|
|
2582
|
+
// acknowledgement is never dropped. Strictly narrower than the delete
|
|
2583
|
+
// it supersedes; dev's watchdog clear above is unaffected.
|
|
2584
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2585
|
+
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2586
|
+
}
|
|
2587
|
+
throw error;
|
|
2588
|
+
}
|
|
2410
2589
|
waiter.awaitingProviderPreflight = false;
|
|
2411
2590
|
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2412
2591
|
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
@@ -2417,354 +2596,370 @@ export class AcpAgent implements Agent {
|
|
|
2417
2596
|
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2418
2597
|
return await response;
|
|
2419
2598
|
}
|
|
2420
|
-
if (record.activePrompt === waiter) {
|
|
2421
|
-
record.activePrompt = undefined;
|
|
2422
|
-
record.busy = record.backgroundBusy;
|
|
2423
|
-
clearPromptWatchdog(waiter);
|
|
2424
|
-
// #5526's guarded release replaces the unguarded delete: it only clears
|
|
2425
|
-
// the entry when it still belongs to THIS waiter, so a successor's
|
|
2426
|
-
// acknowledgement is never dropped. Strictly narrower than the delete
|
|
2427
|
-
// it supersedes; dev's watchdog clear above is unaffected.
|
|
2428
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2429
|
-
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2430
|
-
}
|
|
2431
|
-
throw error;
|
|
2432
|
-
}
|
|
2433
|
-
waiter.awaitingProviderPreflight = false;
|
|
2434
|
-
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2435
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2436
|
-
return await response;
|
|
2437
|
-
}
|
|
2438
|
-
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2439
|
-
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2440
|
-
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2441
|
-
return await response;
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
// Re-arm now that preflight is behind us: the bound armed before it already covered
|
|
2445
|
-
// this prompt's ownership window, and this call replaces that timer rather than
|
|
2446
|
-
// racing it, so the dispatched turn gets the full inactivity bound of its own.
|
|
2447
|
-
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2448
|
-
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
2449
|
-
// transcript from session/update, so without this a prompt's text and any attached
|
|
2450
|
-
// image never appear in the client UI — only the agent's reply does. Replay
|
|
2451
|
-
// (session/load) already emits these; a live turn must too, and the image blocks
|
|
2452
|
-
// must be published verbatim so attachments are visible, not just fed to the model.
|
|
2453
|
-
// A first-turn readiness retry (issue #5574) skips the echo: the message was already
|
|
2454
|
-
// published on the first attempt and re-echoing would duplicate it in the transcript.
|
|
2455
|
-
if (echoUserMessage)
|
|
2456
|
-
for (const block of params.prompt) {
|
|
2457
|
-
if (block.type !== "text" && block.type !== "image") continue;
|
|
2458
|
-
if (block.type === "text" && block.text.length === 0) continue;
|
|
2459
|
-
await this.#publishSessionUpdate(
|
|
2460
|
-
params.sessionId,
|
|
2461
|
-
{
|
|
2462
|
-
sessionId: params.sessionId,
|
|
2463
|
-
update: { sessionUpdate: "user_message_chunk", content: block },
|
|
2464
|
-
},
|
|
2465
|
-
record.adapter,
|
|
2466
|
-
);
|
|
2467
|
-
}
|
|
2468
|
-
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2469
|
-
return await response;
|
|
2470
|
-
}
|
|
2471
|
-
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2472
|
-
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2473
|
-
return await response;
|
|
2474
|
-
}
|
|
2475
|
-
waiter.dispatched = true;
|
|
2476
|
-
// The turn is now dispatched to the host: this prompt owned the session's first turn,
|
|
2477
|
-
// so it — not a preflight rejection that threw before this point — is what settles
|
|
2478
|
-
// `firstPromptDone` in `prompt()` (review P2).
|
|
2479
|
-
if (retryReservation) retryReservation.admitted = true;
|
|
2480
|
-
|
|
2481
|
-
waiter.acknowledgementPending = true;
|
|
2482
|
-
const promptAdapter = record.adapter;
|
|
2483
|
-
const acknowledgementTask = (async (): Promise<PromptResponse> => {
|
|
2484
|
-
if (waiter.settled || record.activePrompt !== waiter) return await response;
|
|
2485
|
-
const acknowledgement = skillInvocation
|
|
2486
|
-
? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
|
|
2487
|
-
: await promptAdapter.prompt({
|
|
2488
|
-
text: payload.text,
|
|
2489
|
-
clientRef,
|
|
2490
|
-
...(payload.images.length ? { images: payload.images } : {}),
|
|
2491
|
-
});
|
|
2492
|
-
// A recovered waiter already owns its exact identity; a late ack cannot rebind it.
|
|
2493
|
-
if (waiter.settled && waiter.acknowledged) return await response;
|
|
2494
2599
|
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
const
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
provisionalOwner.correlation === undefined &&
|
|
2512
|
-
this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
|
|
2513
|
-
) {
|
|
2514
|
-
// A stale ACK cannot bind a provisional abort owner. Keep the fence
|
|
2515
|
-
// until this session is retired or new exact evidence is available.
|
|
2516
|
-
waiter.deferredFrames.length = 0;
|
|
2517
|
-
return await response;
|
|
2518
|
-
}
|
|
2519
|
-
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2520
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2521
|
-
const retainedOwner = current?.uncertainAbortOwner;
|
|
2522
|
-
if (
|
|
2523
|
-
current &&
|
|
2524
|
-
retainedOwner?.kind === waiter.invocationKind &&
|
|
2525
|
-
retainedOwner.clientRef === waiter.clientRef &&
|
|
2526
|
-
retainedOwner.correlation &&
|
|
2527
|
-
correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
|
|
2528
|
-
) {
|
|
2529
|
-
await current.frameTail;
|
|
2530
|
-
const deferredFrames = waiter.deferredFrames.splice(0);
|
|
2531
|
-
let reconciledDeferredTerminal = false;
|
|
2532
|
-
for (const deferred of deferredFrames) {
|
|
2533
|
-
const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
|
|
2534
|
-
if (!deferredEvent) continue;
|
|
2535
|
-
const deferredOutcome = terminalOutcome(deferredEvent);
|
|
2536
|
-
const validDeferredTerminal =
|
|
2537
|
-
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2538
|
-
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2539
|
-
const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
|
|
2540
|
-
if (
|
|
2541
|
-
!validDeferredTerminal ||
|
|
2542
|
-
!sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
|
|
2543
|
-
!deferredCorrelation ||
|
|
2544
|
-
!hasCompleteCorrelation(deferredCorrelation) ||
|
|
2545
|
-
!correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
|
|
2546
|
-
)
|
|
2547
|
-
continue;
|
|
2548
|
-
waiter.terminalReserved = true;
|
|
2549
|
-
await this.#handleSdkFrame(
|
|
2550
|
-
params.sessionId,
|
|
2551
|
-
current.adapter,
|
|
2552
|
-
deferred.frame,
|
|
2553
|
-
current.publicationGeneration,
|
|
2554
|
-
);
|
|
2555
|
-
reconciledDeferredTerminal = true;
|
|
2556
|
-
break;
|
|
2557
|
-
}
|
|
2558
|
-
if (!reconciledDeferredTerminal)
|
|
2559
|
-
void this.#recoverUncertainAbortAfterLateAcknowledgement(
|
|
2560
|
-
params.sessionId,
|
|
2561
|
-
current.adapter,
|
|
2562
|
-
retainedOwner,
|
|
2563
|
-
);
|
|
2564
|
-
} else {
|
|
2565
|
-
waiter.deferredFrames.length = 0;
|
|
2566
|
-
}
|
|
2567
|
-
return await response;
|
|
2568
|
-
}
|
|
2569
|
-
if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
|
|
2570
|
-
try {
|
|
2571
|
-
const abortAcknowledgement = await record.adapter.cancel("turn");
|
|
2572
|
-
if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
|
|
2573
|
-
throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
|
|
2574
|
-
} catch (error) {
|
|
2575
|
-
await this.#failSession(
|
|
2600
|
+
// Re-arm now that preflight is behind us: the bound armed before it already covered
|
|
2601
|
+
// this prompt's ownership window, and this call replaces that timer rather than
|
|
2602
|
+
// racing it, so the dispatched turn gets the full inactivity bound of its own.
|
|
2603
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2604
|
+
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
2605
|
+
// transcript from session/update, so without this a prompt's text and any attached
|
|
2606
|
+
// image never appear in the client UI — only the agent's reply does. Replay
|
|
2607
|
+
// (session/load) already emits these; a live turn must too, and the image blocks
|
|
2608
|
+
// must be published verbatim so attachments are visible, not just fed to the model.
|
|
2609
|
+
// A first-turn readiness retry (issue #5574) skips the echo: the message was already
|
|
2610
|
+
// published on the first attempt and re-echoing would duplicate it in the transcript.
|
|
2611
|
+
if (echoUserMessage)
|
|
2612
|
+
for (const block of params.prompt) {
|
|
2613
|
+
if (block.type !== "text" && block.type !== "image") continue;
|
|
2614
|
+
if (block.type === "text" && block.text.length === 0) continue;
|
|
2615
|
+
await this.#publishSessionUpdate(
|
|
2576
2616
|
params.sessionId,
|
|
2617
|
+
{
|
|
2618
|
+
sessionId: params.sessionId,
|
|
2619
|
+
update: { sessionUpdate: "user_message_chunk", content: block },
|
|
2620
|
+
},
|
|
2577
2621
|
record.adapter,
|
|
2578
|
-
new AcpSdkAdapterError(
|
|
2579
|
-
"connection_closed",
|
|
2580
|
-
`ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
|
|
2581
|
-
),
|
|
2582
2622
|
);
|
|
2583
|
-
throw error;
|
|
2584
2623
|
}
|
|
2585
|
-
|
|
2586
|
-
"invalid_prompt_acknowledgement",
|
|
2587
|
-
"SDK prompt acknowledgement reused a settled commandId/turnId pair.",
|
|
2588
|
-
);
|
|
2589
|
-
}
|
|
2590
|
-
// Retain the acknowledgement ingress boundary with its complete correlation.
|
|
2591
|
-
waiter.boundary = record.inboundSequence;
|
|
2592
|
-
waiter.correlation = acknowledgementCorrelation;
|
|
2593
|
-
waiter.acknowledged = true;
|
|
2594
|
-
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2595
|
-
if (promptWaiterRetired(record, waiter)) {
|
|
2596
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2624
|
+
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2597
2625
|
return await response;
|
|
2598
2626
|
}
|
|
2599
|
-
if (
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
void this.cancel({
|
|
2603
|
-
sessionId: params.sessionId,
|
|
2604
|
-
_meta: { gjc: { abortScope: scope } },
|
|
2605
|
-
} as CancelNotification).catch(error =>
|
|
2606
|
-
logger.warn("ACP could not retry a cancel after prompt admission", {
|
|
2607
|
-
sessionId: params.sessionId,
|
|
2608
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2609
|
-
}),
|
|
2610
|
-
);
|
|
2627
|
+
if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
|
|
2628
|
+
await this.#settleCancelledPrompt(params.sessionId, record, waiter);
|
|
2629
|
+
return await response;
|
|
2611
2630
|
}
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
if (
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2631
|
+
waiter.dispatched = true;
|
|
2632
|
+
record.sdkIdle = false;
|
|
2633
|
+
// The turn is now dispatched to the host: this prompt owned the session's first turn,
|
|
2634
|
+
// so it — not a preflight rejection that threw before this point — is what settles
|
|
2635
|
+
// `firstPromptDone` in `prompt()` (review P2).
|
|
2636
|
+
if (retryReservation) retryReservation.admitted = true;
|
|
2637
|
+
|
|
2638
|
+
waiter.acknowledgementPending = true;
|
|
2639
|
+
const promptAdapter = record.adapter;
|
|
2640
|
+
const acknowledgementTask = (async (): Promise<PromptResponse> => {
|
|
2641
|
+
if (waiter.settled || record.activePrompt !== waiter) return await response;
|
|
2642
|
+
const submit = async (): Promise<unknown> =>
|
|
2643
|
+
skillInvocation
|
|
2644
|
+
? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
|
|
2645
|
+
: await promptAdapter.prompt({
|
|
2646
|
+
text: payload.text,
|
|
2647
|
+
clientRef,
|
|
2648
|
+
...(payload.images.length ? { images: payload.images } : {}),
|
|
2649
|
+
});
|
|
2650
|
+
let acknowledgement: unknown;
|
|
2651
|
+
try {
|
|
2652
|
+
acknowledgement = await submit();
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
if (!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) || error.code !== "busy")
|
|
2655
|
+
throw error;
|
|
2656
|
+
if (
|
|
2657
|
+
record.activePrompt !== waiter ||
|
|
2658
|
+
waiter.settled ||
|
|
2659
|
+
record.cancelRequested ||
|
|
2660
|
+
waiter.cancelAcknowledged ||
|
|
2661
|
+
waiter.cancelBeforeAdmission
|
|
2662
|
+
)
|
|
2663
|
+
throw error;
|
|
2664
|
+
const ready = await this.#waitForSessionIdle(record);
|
|
2665
|
+
if (!ready) throw error;
|
|
2666
|
+
if (
|
|
2667
|
+
record.activePrompt !== waiter ||
|
|
2668
|
+
waiter.settled ||
|
|
2669
|
+
record.cancelRequested ||
|
|
2670
|
+
waiter.cancelAcknowledged ||
|
|
2671
|
+
waiter.cancelBeforeAdmission
|
|
2672
|
+
)
|
|
2673
|
+
throw error;
|
|
2674
|
+
acknowledgement = await submit();
|
|
2675
|
+
}
|
|
2676
|
+
// A recovered waiter already owns its exact identity; a late ack cannot rebind it.
|
|
2677
|
+
if (waiter.settled && waiter.acknowledged) return await response;
|
|
2678
|
+
|
|
2679
|
+
const acknowledgementCorrelation = promptAcknowledgement(acknowledgement);
|
|
2680
|
+
if (!acknowledgementCorrelation)
|
|
2681
|
+
throw new AcpSdkAdapterError(
|
|
2682
|
+
"invalid_prompt_acknowledgement",
|
|
2683
|
+
"SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
|
|
2684
|
+
);
|
|
2685
|
+
if (
|
|
2686
|
+
this.#sessions.get(params.sessionId) !== record ||
|
|
2687
|
+
record.adapter !== promptAdapter ||
|
|
2688
|
+
record.activePrompt !== waiter
|
|
2689
|
+
) {
|
|
2690
|
+
const current = this.#sessions.get(params.sessionId);
|
|
2691
|
+
const provisionalOwner = current?.uncertainAbortOwner;
|
|
2692
|
+
if (
|
|
2693
|
+
provisionalOwner?.kind === waiter.invocationKind &&
|
|
2694
|
+
provisionalOwner.clientRef === waiter.clientRef &&
|
|
2695
|
+
provisionalOwner.correlation === undefined &&
|
|
2696
|
+
this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
|
|
2697
|
+
) {
|
|
2698
|
+
// A stale ACK cannot bind a provisional abort owner. Keep the fence
|
|
2699
|
+
// until this session is retired or new exact evidence is available.
|
|
2700
|
+
waiter.deferredFrames.length = 0;
|
|
2701
|
+
return await response;
|
|
2702
|
+
}
|
|
2703
|
+
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2704
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2705
|
+
const retainedOwner = current?.uncertainAbortOwner;
|
|
2706
|
+
if (
|
|
2707
|
+
current &&
|
|
2708
|
+
retainedOwner?.kind === waiter.invocationKind &&
|
|
2709
|
+
retainedOwner.clientRef === waiter.clientRef &&
|
|
2710
|
+
retainedOwner.correlation &&
|
|
2711
|
+
correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
|
|
2712
|
+
) {
|
|
2713
|
+
await current.frameTail;
|
|
2714
|
+
const deferredFrames = waiter.deferredFrames.splice(0);
|
|
2715
|
+
let reconciledDeferredTerminal = false;
|
|
2716
|
+
for (const deferred of deferredFrames) {
|
|
2717
|
+
const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
|
|
2718
|
+
if (!deferredEvent) continue;
|
|
2719
|
+
const deferredOutcome = terminalOutcome(deferredEvent);
|
|
2720
|
+
const validDeferredTerminal =
|
|
2721
|
+
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2722
|
+
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2723
|
+
const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
|
|
2632
2724
|
if (
|
|
2633
|
-
!
|
|
2634
|
-
|
|
2635
|
-
|
|
2725
|
+
!validDeferredTerminal ||
|
|
2726
|
+
!sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
|
|
2727
|
+
!deferredCorrelation ||
|
|
2728
|
+
!hasCompleteCorrelation(deferredCorrelation) ||
|
|
2729
|
+
!correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
|
|
2636
2730
|
)
|
|
2637
|
-
|
|
2638
|
-
|
|
2731
|
+
continue;
|
|
2732
|
+
waiter.terminalReserved = true;
|
|
2733
|
+
await this.#handleSdkFrame(
|
|
2734
|
+
params.sessionId,
|
|
2735
|
+
current.adapter,
|
|
2736
|
+
deferred.frame,
|
|
2737
|
+
current.publicationGeneration,
|
|
2738
|
+
);
|
|
2739
|
+
reconciledDeferredTerminal = true;
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2742
|
+
if (!reconciledDeferredTerminal)
|
|
2743
|
+
void this.#recoverUncertainAbortAfterLateAcknowledgement(
|
|
2744
|
+
params.sessionId,
|
|
2745
|
+
current.adapter,
|
|
2746
|
+
retainedOwner,
|
|
2747
|
+
);
|
|
2748
|
+
} else {
|
|
2749
|
+
waiter.deferredFrames.length = 0;
|
|
2639
2750
|
}
|
|
2751
|
+
return await response;
|
|
2640
2752
|
}
|
|
2641
|
-
if (
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2649
|
-
const deferred = waiter.deferredFrames.splice(0);
|
|
2650
|
-
for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
|
|
2651
|
-
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2652
|
-
if (!deferredEvent) continue;
|
|
2653
|
-
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
|
|
2654
|
-
const deferredOutcome =
|
|
2655
|
-
deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
|
|
2656
|
-
? terminalOutcome(deferredEvent)
|
|
2657
|
-
: undefined;
|
|
2658
|
-
const deferredIsTerminal =
|
|
2659
|
-
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2660
|
-
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2661
|
-
if (!hasCompleteCorrelation(deferredCorrelation)) {
|
|
2662
|
-
if (deferredIsTerminal)
|
|
2663
|
-
logDroppedPromptTerminal(
|
|
2753
|
+
if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
|
|
2754
|
+
try {
|
|
2755
|
+
const abortAcknowledgement = await record.adapter.cancel("turn");
|
|
2756
|
+
if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
|
|
2757
|
+
throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
|
|
2758
|
+
} catch (error) {
|
|
2759
|
+
await this.#failSession(
|
|
2664
2760
|
params.sessionId,
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2761
|
+
record.adapter,
|
|
2762
|
+
new AcpSdkAdapterError(
|
|
2763
|
+
"connection_closed",
|
|
2764
|
+
`ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
|
|
2765
|
+
),
|
|
2669
2766
|
);
|
|
2670
|
-
|
|
2767
|
+
throw error;
|
|
2768
|
+
}
|
|
2769
|
+
throw new AcpSdkAdapterError(
|
|
2770
|
+
"invalid_prompt_acknowledgement",
|
|
2771
|
+
"SDK prompt acknowledgement reused a settled commandId/turnId pair.",
|
|
2772
|
+
);
|
|
2671
2773
|
}
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
) {
|
|
2678
|
-
|
|
2679
|
-
|
|
2774
|
+
// Retain the acknowledgement ingress boundary with its complete correlation.
|
|
2775
|
+
waiter.boundary = record.inboundSequence;
|
|
2776
|
+
waiter.correlation = acknowledgementCorrelation;
|
|
2777
|
+
waiter.acknowledged = true;
|
|
2778
|
+
this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
|
|
2779
|
+
if (promptWaiterRetired(record, waiter)) {
|
|
2780
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
|
|
2781
|
+
return await response;
|
|
2782
|
+
}
|
|
2783
|
+
if (waiter.cancelBeforeAdmission && record.cancelRequested) {
|
|
2784
|
+
const scope = waiter.cancelBeforeAdmissionScope ?? "turn";
|
|
2785
|
+
// Keep the intent until an acknowledged abort or the prompt terminal settles it.
|
|
2786
|
+
void this.cancel({
|
|
2787
|
+
sessionId: params.sessionId,
|
|
2788
|
+
_meta: { gjc: { abortScope: scope } },
|
|
2789
|
+
} as CancelNotification).catch(error =>
|
|
2790
|
+
logger.warn("ACP could not retry a cancel after prompt admission", {
|
|
2791
|
+
sessionId: params.sessionId,
|
|
2792
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2793
|
+
}),
|
|
2680
2794
|
);
|
|
2681
|
-
record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
|
|
2682
|
-
record.busy = true;
|
|
2683
|
-
continue;
|
|
2684
2795
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2796
|
+
// Frames held while ownership was unknown belong to this prompt only when the
|
|
2797
|
+
// acknowledgement proves their complete correlation matches exactly.
|
|
2798
|
+
const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
|
|
2799
|
+
let observedDeferredActivity = false;
|
|
2800
|
+
for (const deferredFrame of deferredActivityFrames) {
|
|
2801
|
+
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2802
|
+
const matchesWaiter = correlationsExactlyMatch(
|
|
2803
|
+
waiter.correlation,
|
|
2804
|
+
watchdogCorrelationFrom(deferredFrame, deferredEvent),
|
|
2805
|
+
);
|
|
2806
|
+
if (deferredEvent?.type === "agent_start" && !matchesWaiter) {
|
|
2807
|
+
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent);
|
|
2808
|
+
const tombstoned =
|
|
2809
|
+
deferredCorrelation &&
|
|
2810
|
+
record.settledPromptCorrelations.some(settled =>
|
|
2811
|
+
correlationsExactlyMatch(settled, deferredCorrelation),
|
|
2812
|
+
);
|
|
2813
|
+
if (!tombstoned) {
|
|
2814
|
+
record.backgroundBusy = true;
|
|
2815
|
+
if (deferredCorrelation && hasCompleteCorrelation(deferredCorrelation)) {
|
|
2816
|
+
if (
|
|
2817
|
+
!record.backgroundCorrelations.some(owner =>
|
|
2818
|
+
correlationsExactlyMatch(owner, deferredCorrelation),
|
|
2819
|
+
)
|
|
2820
|
+
)
|
|
2821
|
+
record.backgroundCorrelations.push(deferredCorrelation);
|
|
2822
|
+
} else record.backgroundAnonymousCount++;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
if (matchesWaiter) {
|
|
2826
|
+
this.#observePromptActivity(waiter, deferredFrame);
|
|
2827
|
+
if (waiter.observedTurnActivity) record.promptObservedActivity = true;
|
|
2828
|
+
if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
|
|
2829
|
+
observedDeferredActivity = true;
|
|
2830
|
+
}
|
|
2700
2831
|
}
|
|
2701
|
-
if (
|
|
2702
|
-
|
|
2703
|
-
|
|
2832
|
+
if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2833
|
+
const deferred = waiter.deferredFrames.splice(0);
|
|
2834
|
+
for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
|
|
2835
|
+
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
2836
|
+
if (!deferredEvent) continue;
|
|
2837
|
+
const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
|
|
2838
|
+
const deferredOutcome =
|
|
2839
|
+
deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
|
|
2840
|
+
? terminalOutcome(deferredEvent)
|
|
2841
|
+
: undefined;
|
|
2842
|
+
const deferredIsTerminal =
|
|
2843
|
+
(deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
|
|
2844
|
+
(deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
|
|
2845
|
+
if (!hasCompleteCorrelation(deferredCorrelation)) {
|
|
2846
|
+
if (deferredIsTerminal)
|
|
2847
|
+
logDroppedPromptTerminal(
|
|
2848
|
+
params.sessionId,
|
|
2849
|
+
deferredEvent,
|
|
2850
|
+
"incomplete_correlation",
|
|
2851
|
+
deferredCorrelation,
|
|
2852
|
+
waiter.correlation,
|
|
2853
|
+
);
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
const matchesPrompt = correlationsExactlyMatch(waiter.correlation, deferredCorrelation);
|
|
2857
|
+
if (
|
|
2858
|
+
!matchesPrompt &&
|
|
2859
|
+
deferredIsTerminal &&
|
|
2860
|
+
record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, deferredCorrelation))
|
|
2861
|
+
) {
|
|
2862
|
+
record.backgroundCorrelations = record.backgroundCorrelations.filter(
|
|
2863
|
+
owner => !correlationsExactlyMatch(owner, deferredCorrelation),
|
|
2864
|
+
);
|
|
2865
|
+
record.backgroundBusy =
|
|
2866
|
+
record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
|
|
2867
|
+
record.busy = true;
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
if (
|
|
2871
|
+
!matchesPrompt &&
|
|
2872
|
+
record.settledPromptCorrelations.some(settled =>
|
|
2873
|
+
correlationsExactlyMatch(settled, deferredCorrelation),
|
|
2874
|
+
)
|
|
2875
|
+
)
|
|
2876
|
+
continue;
|
|
2877
|
+
if (!matchesPrompt) {
|
|
2878
|
+
if (deferredIsTerminal)
|
|
2879
|
+
logDroppedPromptTerminal(
|
|
2880
|
+
params.sessionId,
|
|
2881
|
+
deferredEvent,
|
|
2882
|
+
"correlation_mismatch",
|
|
2883
|
+
deferredCorrelation,
|
|
2884
|
+
waiter.correlation,
|
|
2885
|
+
);
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
if (deferredIsTerminal) {
|
|
2889
|
+
waiter.terminalReserved = true;
|
|
2890
|
+
clearPromptWatchdog(waiter);
|
|
2891
|
+
}
|
|
2892
|
+
const task = record.frameTail.then(
|
|
2893
|
+
async () =>
|
|
2894
|
+
await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
|
|
2895
|
+
);
|
|
2896
|
+
record.frameTail = task.catch(async error => {
|
|
2897
|
+
if (deferredGeneration !== record.publicationGeneration) return;
|
|
2898
|
+
if (record.activePrompt?.terminalReserved) return;
|
|
2899
|
+
await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error));
|
|
2900
|
+
});
|
|
2704
2901
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2902
|
+
this.#settlePrompt(params.sessionId, record, waiter);
|
|
2903
|
+
return await response;
|
|
2904
|
+
})()
|
|
2905
|
+
.catch(async error => {
|
|
2906
|
+
if (
|
|
2907
|
+
!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) ||
|
|
2908
|
+
error.code !== "uncertain_after_send"
|
|
2909
|
+
)
|
|
2910
|
+
throw error;
|
|
2911
|
+
if (record.adapter === promptAdapter)
|
|
2912
|
+
this.#startUncertainPromptRecovery(params.sessionId, record, waiter);
|
|
2913
|
+
return await response;
|
|
2914
|
+
})
|
|
2915
|
+
.finally(() => {
|
|
2916
|
+
waiter.acknowledgementPending = false;
|
|
2917
|
+
this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
|
|
2713
2918
|
});
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2919
|
+
// Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
|
|
2920
|
+
// must remain alive to tombstone its eventual correlation without holding the ACP caller.
|
|
2921
|
+
void acknowledgementTask.catch(() => undefined);
|
|
2922
|
+
try {
|
|
2923
|
+
return await Promise.race([response, acknowledgementTask]);
|
|
2924
|
+
} catch (error) {
|
|
2925
|
+
if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
|
|
2926
|
+
waiter.deferredFrames.length = 0;
|
|
2927
|
+
waiter.deferredActivityFrames.length = 0;
|
|
2928
|
+
clearPromptWatchdog(waiter);
|
|
2929
|
+
waiter.terminal = undefined;
|
|
2930
|
+
waiter.settled = true;
|
|
2931
|
+
if (record.activePrompt === waiter) {
|
|
2932
|
+
record.activePrompt = undefined;
|
|
2933
|
+
record.busy = record.backgroundBusy;
|
|
2934
|
+
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2935
|
+
}
|
|
2936
|
+
// Keep a late terminal for this (cancelled) turn closed, exactly like the
|
|
2937
|
+
// other settlement paths, so an aborted run's trailing frame can never
|
|
2938
|
+
// publish over a later prompt that reuses the identity.
|
|
2939
|
+
this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
|
|
2940
|
+
// A prompt cancelled before the SDK acknowledged it still ends this turn by
|
|
2941
|
+
// client request, and ACP is explicit: "Agents MUST catch these errors and
|
|
2942
|
+
// return the semantically meaningful `cancelled` stop reason, so that Clients
|
|
2943
|
+
// can reliably confirm the cancellation." Surfacing the transport's `busy`
|
|
2944
|
+
// rejection instead would show the user a spurious error for their own cancel.
|
|
2719
2945
|
if (
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
// must remain alive to tombstone its eventual correlation without holding the ACP caller.
|
|
2733
|
-
void acknowledgementTask.catch(() => undefined);
|
|
2734
|
-
try {
|
|
2735
|
-
return await Promise.race([response, acknowledgementTask]);
|
|
2736
|
-
} catch (error) {
|
|
2737
|
-
if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
|
|
2738
|
-
waiter.deferredFrames.length = 0;
|
|
2739
|
-
waiter.deferredActivityFrames.length = 0;
|
|
2740
|
-
clearPromptWatchdog(waiter);
|
|
2741
|
-
waiter.terminal = undefined;
|
|
2742
|
-
waiter.settled = true;
|
|
2743
|
-
if (record.activePrompt === waiter) {
|
|
2744
|
-
record.activePrompt = undefined;
|
|
2745
|
-
record.busy = record.backgroundBusy;
|
|
2746
|
-
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2747
|
-
}
|
|
2748
|
-
// Keep a late terminal for this (cancelled) turn closed, exactly like the
|
|
2749
|
-
// other settlement paths, so an aborted run's trailing frame can never
|
|
2750
|
-
// publish over a later prompt that reuses the identity.
|
|
2751
|
-
this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
|
|
2752
|
-
// A prompt cancelled before the SDK acknowledged it still ends this turn by
|
|
2753
|
-
// client request, and ACP is explicit: "Agents MUST catch these errors and
|
|
2754
|
-
// return the semantically meaningful `cancelled` stop reason, so that Clients
|
|
2755
|
-
// can reliably confirm the cancellation." Surfacing the transport's `busy`
|
|
2756
|
-
// rejection instead would show the user a spurious error for their own cancel.
|
|
2757
|
-
if (
|
|
2758
|
-
record.cancelRequested &&
|
|
2759
|
-
(waiter.cancelAcknowledged || (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
|
|
2760
|
-
) {
|
|
2761
|
-
record.cancelRequested = false;
|
|
2762
|
-
// The client's turn is settled by the return; the advisory idle publication
|
|
2763
|
-
// must not gate it and must still be attempted so the running phase is
|
|
2764
|
-
// released (gjcRunning:false) instead of spinning behind a settled cancel.
|
|
2765
|
-
return { stopReason: "cancelled" };
|
|
2946
|
+
record.cancelRequested &&
|
|
2947
|
+
(waiter.cancelAcknowledged ||
|
|
2948
|
+
(waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
|
|
2949
|
+
) {
|
|
2950
|
+
record.cancelRequested = false;
|
|
2951
|
+
waiter.resolve({ stopReason: "cancelled" });
|
|
2952
|
+
// The client's turn is settled by the return; the advisory idle publication
|
|
2953
|
+
// must not gate it and must still be attempted so the running phase is
|
|
2954
|
+
// released (gjcRunning:false) instead of spinning behind a settled cancel.
|
|
2955
|
+
return { stopReason: "cancelled" };
|
|
2956
|
+
}
|
|
2957
|
+
throw error;
|
|
2766
2958
|
}
|
|
2767
|
-
|
|
2959
|
+
} finally {
|
|
2960
|
+
// Exception exits can release ownership without settling the public response.
|
|
2961
|
+
// Wake successor admission without replacing the predecessor's original result.
|
|
2962
|
+
completeOwnership();
|
|
2768
2963
|
}
|
|
2769
2964
|
}
|
|
2770
2965
|
|
|
@@ -2774,6 +2969,7 @@ export class AcpAgent implements Agent {
|
|
|
2774
2969
|
// Record the client's intent before awaiting the SDK so a prompt that rejects
|
|
2775
2970
|
// mid-cancel (e.g. preflight `busy`) can still settle as `cancelled`.
|
|
2776
2971
|
record.cancelRequested = true;
|
|
2972
|
+
this.#settlePendingPromptAdmission(record, { kind: "cancelled" });
|
|
2777
2973
|
// C04 terminal abort: an external client cancel stops the current turn
|
|
2778
2974
|
// (`scope:"turn"`, the default, matching the SDK `turn.abort` default and
|
|
2779
2975
|
// other ACP clients' cancel behavior). A client that also wants exact owned
|
|
@@ -2944,6 +3140,16 @@ export class AcpAgent implements Agent {
|
|
|
2944
3140
|
}
|
|
2945
3141
|
}
|
|
2946
3142
|
|
|
3143
|
+
#settlePendingPromptAdmission(record: SessionRecord, settlement: PromptAdmissionSettlement): void {
|
|
3144
|
+
const reservation = record.pendingPromptAdmission;
|
|
3145
|
+
if (!reservation) return;
|
|
3146
|
+
reservation.settlement ??= settlement;
|
|
3147
|
+
if (settlement.kind === "cancelled") reservation.cancelled = true;
|
|
3148
|
+
reservation.wake();
|
|
3149
|
+
for (const resolve of record.idleWaiters) resolve();
|
|
3150
|
+
record.idleWaiters.clear();
|
|
3151
|
+
}
|
|
3152
|
+
|
|
2947
3153
|
/**
|
|
2948
3154
|
* `aborted: true` means the run is gone, so the pending prompt is already over even
|
|
2949
3155
|
* if no normalized terminal follows. Without this the waiter stays pending forever:
|
|
@@ -3249,6 +3455,7 @@ export class AcpAgent implements Agent {
|
|
|
3249
3455
|
this.#pendingRouterFrames.set(id, bufferedFrames);
|
|
3250
3456
|
try {
|
|
3251
3457
|
await this.#ensureRouterReady();
|
|
3458
|
+
await this.#router.reconcile({ waitForReplay: false });
|
|
3252
3459
|
if (lifecycleResult) await this.#router.adoptLifecycleResult(lifecycleResult, { sessionId: id, cwd });
|
|
3253
3460
|
let currentAttachment = this.#router.attachment(id);
|
|
3254
3461
|
for (let attempt = 0; !currentAttachment && attempt < 40; attempt++) {
|
|
@@ -3316,6 +3523,8 @@ export class AcpAgent implements Agent {
|
|
|
3316
3523
|
backgroundBusy: retainedAbortOwner !== undefined,
|
|
3317
3524
|
backgroundAnonymousCount: 0,
|
|
3318
3525
|
backgroundCorrelations: retainedAbortOwner?.correlation ? [retainedAbortOwner.correlation] : [],
|
|
3526
|
+
sdkIdle: retainedAbortOwner === undefined,
|
|
3527
|
+
idleWaiters: new Set(),
|
|
3319
3528
|
toolArgs: new Map(),
|
|
3320
3529
|
uncertainAbortOwner: retainedAbortOwner,
|
|
3321
3530
|
};
|
|
@@ -4006,6 +4215,17 @@ export class AcpAgent implements Agent {
|
|
|
4006
4215
|
};
|
|
4007
4216
|
record.pendingFirstPromptRetry = undefined;
|
|
4008
4217
|
}
|
|
4218
|
+
const admissionReservation = record.pendingPromptAdmission;
|
|
4219
|
+
if (admissionReservation) {
|
|
4220
|
+
admissionReservation.settlement ??= voluntary
|
|
4221
|
+
? { kind: "cancelled" }
|
|
4222
|
+
: {
|
|
4223
|
+
kind: "rejected",
|
|
4224
|
+
error: new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`),
|
|
4225
|
+
};
|
|
4226
|
+
admissionReservation.wake();
|
|
4227
|
+
record.pendingPromptAdmission = undefined;
|
|
4228
|
+
}
|
|
4009
4229
|
}
|
|
4010
4230
|
|
|
4011
4231
|
const failures: unknown[] = [];
|
|
@@ -4059,6 +4279,11 @@ export class AcpAgent implements Agent {
|
|
|
4059
4279
|
this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
|
|
4060
4280
|
this.#fenceRetiredPromptAcknowledgement(id, waiter);
|
|
4061
4281
|
}
|
|
4282
|
+
if (record.pendingPromptAdmission) {
|
|
4283
|
+
record.pendingPromptAdmission.settlement = { kind: "rejected", error };
|
|
4284
|
+
record.pendingPromptAdmission.wake();
|
|
4285
|
+
record.pendingPromptAdmission = undefined;
|
|
4286
|
+
}
|
|
4062
4287
|
waiter?.reject(error);
|
|
4063
4288
|
try {
|
|
4064
4289
|
await adapter.close();
|
|
@@ -4128,12 +4353,14 @@ export class AcpAgent implements Agent {
|
|
|
4128
4353
|
#observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
|
|
4129
4354
|
if (frame.type === "activity") {
|
|
4130
4355
|
if (frame.state === "busy") {
|
|
4356
|
+
record.sdkIdle = false;
|
|
4131
4357
|
record.busy = true;
|
|
4132
4358
|
if (!record.activePrompt) {
|
|
4133
4359
|
record.backgroundBusy = true;
|
|
4134
4360
|
record.backgroundAnonymousCount = Math.max(record.backgroundAnonymousCount, 1);
|
|
4135
4361
|
}
|
|
4136
4362
|
} else if (frame.state === "idle") {
|
|
4363
|
+
record.sdkIdle = true;
|
|
4137
4364
|
if (record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0) {
|
|
4138
4365
|
record.busy = true;
|
|
4139
4366
|
record.backgroundBusy = true;
|
|
@@ -4143,6 +4370,8 @@ export class AcpAgent implements Agent {
|
|
|
4143
4370
|
record.backgroundBusy = false;
|
|
4144
4371
|
record.backgroundAnonymousCount = 0;
|
|
4145
4372
|
record.backgroundCorrelations.length = 0;
|
|
4373
|
+
for (const resolve of record.idleWaiters) resolve();
|
|
4374
|
+
record.idleWaiters.clear();
|
|
4146
4375
|
}
|
|
4147
4376
|
return;
|
|
4148
4377
|
}
|
|
@@ -4176,6 +4405,20 @@ export class AcpAgent implements Agent {
|
|
|
4176
4405
|
}
|
|
4177
4406
|
}
|
|
4178
4407
|
|
|
4408
|
+
/** Wait for the SDK's winding-down turn to publish idle before retrying a fresh prompt. */
|
|
4409
|
+
#waitForSessionIdle(record: SessionRecord): Promise<boolean> {
|
|
4410
|
+
if (record.sdkIdle && !record.backgroundBusy) return Promise.resolve(true);
|
|
4411
|
+
const deferred = Promise.withResolvers<void>();
|
|
4412
|
+
record.idleWaiters.add(deferred.resolve);
|
|
4413
|
+
const cancel = this.#promptWatchdogClock.schedule(deferred.resolve, ACP_BUSY_SETTLE_WAIT_MS);
|
|
4414
|
+
return deferred.promise
|
|
4415
|
+
.then(() => record.sdkIdle && !record.backgroundBusy)
|
|
4416
|
+
.finally(() => {
|
|
4417
|
+
record.idleWaiters.delete(deferred.resolve);
|
|
4418
|
+
cancel();
|
|
4419
|
+
});
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4179
4422
|
#frameProcessingFailure(error: unknown): AcpSdkAdapterError {
|
|
4180
4423
|
if (error instanceof AcpSdkAdapterError && error.code === "frame_processing_failed") return error;
|
|
4181
4424
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -4249,6 +4492,37 @@ export class AcpAgent implements Agent {
|
|
|
4249
4492
|
return undefined;
|
|
4250
4493
|
}
|
|
4251
4494
|
|
|
4495
|
+
/** Settles the active prompt when the host explicitly reports that its session closed. */
|
|
4496
|
+
#settlePromptAfterHostClose(id: string, reason: string): void {
|
|
4497
|
+
const record = this.#sessions.get(id);
|
|
4498
|
+
if (!record) return;
|
|
4499
|
+
const waiter = record.activePrompt;
|
|
4500
|
+
if (!waiter || waiter.settled || waiter.terminalReserved) return;
|
|
4501
|
+
if (record.cancelRequested && waiter.cancelAcknowledged) {
|
|
4502
|
+
void this.#settleCancelledPrompt(id, record, waiter);
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4505
|
+
// An accepted mutation without its acknowledgement remains uncertain. Keep the
|
|
4506
|
+
// existing reconciliation path so a host exit never turns an ambiguous prompt
|
|
4507
|
+
// into a retryable failure.
|
|
4508
|
+
if (waiter.dispatched && !waiter.acknowledged) {
|
|
4509
|
+
this.#startUncertainPromptRecovery(id, record, waiter);
|
|
4510
|
+
return;
|
|
4511
|
+
}
|
|
4512
|
+
record.busy = record.backgroundBusy;
|
|
4513
|
+
void this.#rejectPrompt(
|
|
4514
|
+
record,
|
|
4515
|
+
id,
|
|
4516
|
+
waiter,
|
|
4517
|
+
new AcpPromptAbandonedError(
|
|
4518
|
+
"prompt_abandoned",
|
|
4519
|
+
`ACP prompt was abandoned because the SDK session host closed (${reason}). The turn was settled so the ` +
|
|
4520
|
+
`client stops waiting; a new prompt requires a valid or re-established session attachment.`,
|
|
4521
|
+
waiter.planSnapshot,
|
|
4522
|
+
),
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4252
4526
|
/**
|
|
4253
4527
|
* Settles the ACP prompt only. The agent's own work is left alone: this reports that the
|
|
4254
4528
|
* turn can no longer be observed, it does not cancel or tear down the session.
|
|
@@ -4322,6 +4596,14 @@ export class AcpAgent implements Agent {
|
|
|
4322
4596
|
else logger.warn(`ACP session ${id} dropped an event from a foreign session identity.`);
|
|
4323
4597
|
return;
|
|
4324
4598
|
}
|
|
4599
|
+
if (ingressEvent?.type === "session_closed" || ingressEvent?.type === "session_terminated") {
|
|
4600
|
+
const reason =
|
|
4601
|
+
typeof ingressEvent.reason === "string" && ingressEvent.reason.length > 0
|
|
4602
|
+
? ingressEvent.reason
|
|
4603
|
+
: ingressEvent.type;
|
|
4604
|
+
this.#settlePromptAfterHostClose(id, reason);
|
|
4605
|
+
return;
|
|
4606
|
+
}
|
|
4325
4607
|
if (frame.type !== "hello" && frame.type !== "server_hello" && typeof frame.connectionId === "string")
|
|
4326
4608
|
record.connectionId = frame.connectionId;
|
|
4327
4609
|
// Ingress ordering is recorded before queued work begins.
|
|
@@ -5128,6 +5410,78 @@ export class AcpAgent implements Agent {
|
|
|
5128
5410
|
}
|
|
5129
5411
|
}
|
|
5130
5412
|
|
|
5413
|
+
/**
|
|
5414
|
+
* Wait for the model to settle when no explicit --model was passed but a default might be configured.
|
|
5415
|
+
* Give each probe its own timeout clamped to the remaining budget so wedged probes cannot exceed 500ms total.
|
|
5416
|
+
* Catch probe errors as advisory so a rejected probe does not discard the session (issue #6009).
|
|
5417
|
+
*/
|
|
5418
|
+
async #waitForModelSettle(id: string): Promise<void> {
|
|
5419
|
+
const record = this.#sessions.get(id);
|
|
5420
|
+
if (!record) return; // Session was closed; skip settlement wait.
|
|
5421
|
+
|
|
5422
|
+
// If an explicit --model was passed, it's already settled by applyAcpStartupOptions.
|
|
5423
|
+
if (this.#startupOptions?.modelId) return;
|
|
5424
|
+
|
|
5425
|
+
// Set the deadline once for all probes (including the first).
|
|
5426
|
+
const deadline = Date.now() + ACP_MODEL_SETTLEMENT_TIMEOUT_MS;
|
|
5427
|
+
|
|
5428
|
+
// Query the config once to check if the model is already settled.
|
|
5429
|
+
// Even the first probe gets its own timeout clamped to the remaining budget.
|
|
5430
|
+
let config: unknown;
|
|
5431
|
+
const remaining = deadline - Date.now();
|
|
5432
|
+
if (remaining > 0) {
|
|
5433
|
+
try {
|
|
5434
|
+
const controller = new AbortController();
|
|
5435
|
+
const timeoutHandle = setTimeout(() => controller.abort(), remaining);
|
|
5436
|
+
try {
|
|
5437
|
+
const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
|
|
5438
|
+
const listener = () => rejectTimeout(new Error("probe_timeout"));
|
|
5439
|
+
controller.signal.addEventListener("abort", listener);
|
|
5440
|
+
config = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
|
|
5441
|
+
} finally {
|
|
5442
|
+
clearTimeout(timeoutHandle);
|
|
5443
|
+
}
|
|
5444
|
+
} catch {
|
|
5445
|
+
// Advisory: ignore first probe error and proceed to retry loop.
|
|
5446
|
+
// The first probe's failure doesn't mean the model won't settle later.
|
|
5447
|
+
config = undefined;
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
const currentModel = config !== undefined ? configValues(config).get(MODEL_CONFIG_ID) : undefined;
|
|
5451
|
+
if (currentModel !== undefined) return; // Model is settled.
|
|
5452
|
+
if (remaining <= 0) return; // Budget exhausted before entering retry loop.
|
|
5453
|
+
|
|
5454
|
+
// No explicit model and no settled value: the SDK may be applying a default.
|
|
5455
|
+
// Retry with a bounded timeout, where each probe gets its own timeout clamped to the remaining budget.
|
|
5456
|
+
// Only wait if there's a reasonable chance a default is configured (no model after first probe).
|
|
5457
|
+
while (Date.now() < deadline) {
|
|
5458
|
+
await Bun.sleep(50); // Small delay before retry.
|
|
5459
|
+
const remaining = deadline - Date.now();
|
|
5460
|
+
if (remaining <= 0) break; // Budget exhausted.
|
|
5461
|
+
|
|
5462
|
+
let nextConfig: unknown;
|
|
5463
|
+
try {
|
|
5464
|
+
// Give this probe its own timeout clamped to the remaining budget.
|
|
5465
|
+
// A wedged probe will timeout after `remaining` ms, not the full SDK timeout.
|
|
5466
|
+
const controller = new AbortController();
|
|
5467
|
+
const timeoutHandle = setTimeout(() => controller.abort(), remaining);
|
|
5468
|
+
try {
|
|
5469
|
+
const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
|
|
5470
|
+
const listener = () => rejectTimeout(new Error("probe_timeout"));
|
|
5471
|
+
controller.signal.addEventListener("abort", listener);
|
|
5472
|
+
nextConfig = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
|
|
5473
|
+
} finally {
|
|
5474
|
+
clearTimeout(timeoutHandle);
|
|
5475
|
+
}
|
|
5476
|
+
} catch {
|
|
5477
|
+
// Advisory: probe failed or timed out; continue retrying while budget remains.
|
|
5478
|
+
continue;
|
|
5479
|
+
}
|
|
5480
|
+
if (configValues(nextConfig).get(MODEL_CONFIG_ID) !== undefined) return; // Settled.
|
|
5481
|
+
}
|
|
5482
|
+
// Budget exhausted: proceed without model. The model option may be missing from the response.
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5131
5485
|
async #sessionState(
|
|
5132
5486
|
id: string,
|
|
5133
5487
|
rejectUnavailableStartupPreset = false,
|