condukt 0.25.0 → 0.27.0
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/dist/runtimes/claude/bridge-cache.d.ts +58 -0
- package/dist/runtimes/claude/bridge-cache.d.ts.map +1 -0
- package/dist/runtimes/claude/bridge-cache.js +257 -0
- package/dist/runtimes/claude/bridge-cache.js.map +1 -0
- package/dist/runtimes/claude/claude-runtime.d.ts +30 -0
- package/dist/runtimes/claude/claude-runtime.d.ts.map +1 -0
- package/dist/runtimes/claude/claude-runtime.js +88 -0
- package/dist/runtimes/claude/claude-runtime.js.map +1 -0
- package/dist/runtimes/claude/claude-session.d.ts +154 -0
- package/dist/runtimes/claude/claude-session.d.ts.map +1 -0
- package/dist/runtimes/claude/claude-session.js +851 -0
- package/dist/runtimes/claude/claude-session.js.map +1 -0
- package/dist/runtimes/claude/config-mapper.d.ts +69 -0
- package/dist/runtimes/claude/config-mapper.d.ts.map +1 -0
- package/dist/runtimes/claude/config-mapper.js +458 -0
- package/dist/runtimes/claude/config-mapper.js.map +1 -0
- package/dist/runtimes/claude/epoch-store.d.ts +28 -0
- package/dist/runtimes/claude/epoch-store.d.ts.map +1 -0
- package/dist/runtimes/claude/epoch-store.js +199 -0
- package/dist/runtimes/claude/epoch-store.js.map +1 -0
- package/dist/runtimes/claude/fallback-runtime.d.ts +14 -0
- package/dist/runtimes/claude/fallback-runtime.d.ts.map +1 -0
- package/dist/runtimes/claude/fallback-runtime.js +64 -0
- package/dist/runtimes/claude/fallback-runtime.js.map +1 -0
- package/dist/runtimes/claude/index.d.ts +24 -0
- package/dist/runtimes/claude/index.d.ts.map +1 -0
- package/dist/runtimes/claude/index.js +61 -0
- package/dist/runtimes/claude/index.js.map +1 -0
- package/dist/runtimes/claude/message-mapper.d.ts +135 -0
- package/dist/runtimes/claude/message-mapper.d.ts.map +1 -0
- package/dist/runtimes/claude/message-mapper.js +504 -0
- package/dist/runtimes/claude/message-mapper.js.map +1 -0
- package/dist/runtimes/claude/one-shot.d.ts +48 -0
- package/dist/runtimes/claude/one-shot.d.ts.map +1 -0
- package/dist/runtimes/claude/one-shot.js +186 -0
- package/dist/runtimes/claude/one-shot.js.map +1 -0
- package/dist/runtimes/claude/protocol-bridge.d.ts +138 -0
- package/dist/runtimes/claude/protocol-bridge.d.ts.map +1 -0
- package/dist/runtimes/claude/protocol-bridge.js +1170 -0
- package/dist/runtimes/claude/protocol-bridge.js.map +1 -0
- package/dist/runtimes/claude/protocol-router.d.ts +113 -0
- package/dist/runtimes/claude/protocol-router.d.ts.map +1 -0
- package/dist/runtimes/claude/protocol-router.js +514 -0
- package/dist/runtimes/claude/protocol-router.js.map +1 -0
- package/dist/runtimes/claude/session-epoch.d.ts +156 -0
- package/dist/runtimes/claude/session-epoch.d.ts.map +1 -0
- package/dist/runtimes/claude/session-epoch.js +365 -0
- package/dist/runtimes/claude/session-epoch.js.map +1 -0
- package/dist/runtimes/claude/session-recovery.d.ts +78 -0
- package/dist/runtimes/claude/session-recovery.d.ts.map +1 -0
- package/dist/runtimes/claude/session-recovery.js +259 -0
- package/dist/runtimes/claude/session-recovery.js.map +1 -0
- package/dist/runtimes/claude/sse.d.ts +16 -0
- package/dist/runtimes/claude/sse.d.ts.map +1 -0
- package/dist/runtimes/claude/sse.js +46 -0
- package/dist/runtimes/claude/sse.js.map +1 -0
- package/dist/runtimes/claude/translate-chat.d.ts +106 -0
- package/dist/runtimes/claude/translate-chat.d.ts.map +1 -0
- package/dist/runtimes/claude/translate-chat.js +857 -0
- package/dist/runtimes/claude/translate-chat.js.map +1 -0
- package/dist/runtimes/claude/translate-responses.d.ts +76 -0
- package/dist/runtimes/claude/translate-responses.d.ts.map +1 -0
- package/dist/runtimes/claude/translate-responses.js +625 -0
- package/dist/runtimes/claude/translate-responses.js.map +1 -0
- package/dist/runtimes/claude/usage-ledger.d.ts +108 -0
- package/dist/runtimes/claude/usage-ledger.d.ts.map +1 -0
- package/dist/runtimes/claude/usage-ledger.js +189 -0
- package/dist/runtimes/claude/usage-ledger.js.map +1 -0
- package/dist/runtimes/copilot/copilot-backend.d.ts +8 -0
- package/dist/runtimes/copilot/copilot-backend.d.ts.map +1 -1
- package/dist/runtimes/copilot/sdk-backend.d.ts.map +1 -1
- package/dist/runtimes/copilot/sdk-backend.js +314 -135
- package/dist/runtimes/copilot/sdk-backend.js.map +1 -1
- package/dist/runtimes/mcp-env.d.ts +24 -0
- package/dist/runtimes/mcp-env.d.ts.map +1 -0
- package/dist/runtimes/mcp-env.js +81 -0
- package/dist/runtimes/mcp-env.js.map +1 -0
- package/dist/runtimes/mock/mock-runtime.d.ts +2 -2
- package/dist/runtimes/mock/mock-runtime.d.ts.map +1 -1
- package/dist/runtimes/mock/mock-runtime.js +38 -1
- package/dist/runtimes/mock/mock-runtime.js.map +1 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +171 -3
- package/dist/src/agent.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/session-recovery-policy.d.ts +19 -0
- package/dist/src/session-recovery-policy.d.ts.map +1 -0
- package/dist/src/session-recovery-policy.js +43 -0
- package/dist/src/session-recovery-policy.js.map +1 -0
- package/dist/src/types.d.ts +118 -1
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +11 -1
- package/dist/src/types.js.map +1 -1
- package/package.json +20 -4
|
@@ -51,6 +51,8 @@ const fs = __importStar(require("fs"));
|
|
|
51
51
|
const path = __importStar(require("path"));
|
|
52
52
|
const node_crypto_1 = require("node:crypto");
|
|
53
53
|
const types_1 = require("../../src/types");
|
|
54
|
+
const session_recovery_policy_1 = require("../../src/session-recovery-policy");
|
|
55
|
+
const mcp_env_1 = require("../mcp-env");
|
|
54
56
|
const lifecycle_events_1 = require("./lifecycle-events");
|
|
55
57
|
const subagents_1 = require("./subagents");
|
|
56
58
|
const SEVERITY_RANK = {
|
|
@@ -81,35 +83,32 @@ function writeDiagnosticStderr(severity, message, configuredLevel) {
|
|
|
81
83
|
/* closed stream */
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
backoffBaseMs: positiveIntegerOr(configured.backoffBaseMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.backoffBaseMs),
|
|
103
|
-
backoffMaxMs: positiveIntegerOr(configured.backoffMaxMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.backoffMaxMs),
|
|
104
|
-
jitter: configured.jitter ?? types_1.DEFAULT_SESSION_RECOVERY_POLICY.jitter,
|
|
105
|
-
nativeRetryGraceMs: nonNegativeOr(configured.nativeRetryGraceMs, types_1.DEFAULT_SESSION_RECOVERY_POLICY.nativeRetryGraceMs),
|
|
106
|
-
};
|
|
86
|
+
/**
|
|
87
|
+
* A failed reconnect step, tagged with the stage that threw so an `exhausted`
|
|
88
|
+
* event can name it.
|
|
89
|
+
*
|
|
90
|
+
* `fatal` marks the two steps whose failure cannot change its answer on a
|
|
91
|
+
* retry: a missing client factory, and a resumed session whose id is not the one
|
|
92
|
+
* we asked for. Every other step is retried until the continuation count or the
|
|
93
|
+
* recovery budget runs out.
|
|
94
|
+
*/
|
|
95
|
+
class SessionResumeAttemptError extends Error {
|
|
96
|
+
stage;
|
|
97
|
+
fatal;
|
|
98
|
+
constructor(stage, message, fatal, options) {
|
|
99
|
+
super(message, options);
|
|
100
|
+
this.stage = stage;
|
|
101
|
+
this.fatal = fatal;
|
|
102
|
+
this.name = 'SessionResumeAttemptError';
|
|
103
|
+
}
|
|
107
104
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
return
|
|
105
|
+
function toResumeAttemptError(value, stage) {
|
|
106
|
+
if (value instanceof SessionResumeAttemptError)
|
|
107
|
+
return value;
|
|
108
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
109
|
+
return new SessionResumeAttemptError(stage, message, false, {
|
|
110
|
+
cause: value instanceof Error ? value : undefined,
|
|
111
|
+
});
|
|
113
112
|
}
|
|
114
113
|
function numericStatusCode(value) {
|
|
115
114
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
@@ -119,6 +118,48 @@ function numericStatusCode(value) {
|
|
|
119
118
|
const parsed = Number(value);
|
|
120
119
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
121
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The CLI and the SDK describe a failed model call differently.
|
|
123
|
+
*
|
|
124
|
+
* The SDK surfaces `model.call_failure` in its typed shape: `source`,
|
|
125
|
+
* `failureKind`, `transport`, `errorMessage` and friends, flat and camelCase,
|
|
126
|
+
* with `source` always set. The CLI emits `model.model_call_failure` (routed
|
|
127
|
+
* here by the type-substring branch in `dispatchClassEvent`) in its own
|
|
128
|
+
* envelope: the details nested under `modelCall` in snake_case, and no `source`
|
|
129
|
+
* anywhere.
|
|
130
|
+
*
|
|
131
|
+
* Both land in `handleModelCallFailure`, so classification reads one view of
|
|
132
|
+
* either. Without this the recovery predicate, which tests `source ===
|
|
133
|
+
* 'top_level'` first, rejected every transport failure that arrived in the CLI
|
|
134
|
+
* shape, and the run restarted the node from a cold session instead of
|
|
135
|
+
* resuming it.
|
|
136
|
+
*
|
|
137
|
+
* The original envelope is kept alongside the unwrapped fields: the failure log
|
|
138
|
+
* still shows exactly what arrived, and the added keys are the ones the
|
|
139
|
+
* classifier reads.
|
|
140
|
+
*/
|
|
141
|
+
function normalizeModelCallFailure(data) {
|
|
142
|
+
if (!data)
|
|
143
|
+
return data;
|
|
144
|
+
const nested = isRecord(data.modelCall) ? data.modelCall : undefined;
|
|
145
|
+
// Already the flat SDK shape, or nothing to unwrap: leave it alone.
|
|
146
|
+
if (!nested)
|
|
147
|
+
return data;
|
|
148
|
+
return {
|
|
149
|
+
...data,
|
|
150
|
+
failureKind: data.failureKind ?? nested.failure_kind ?? nested.failureKind,
|
|
151
|
+
transport: data.transport ?? nested.transport,
|
|
152
|
+
errorMessage: data.errorMessage ?? nested.error ?? nested.errorMessage,
|
|
153
|
+
errorCode: data.errorCode ?? nested.error_code ?? nested.errorCode,
|
|
154
|
+
apiEndpoint: data.apiEndpoint ?? nested.api_endpoint,
|
|
155
|
+
model: data.model ?? nested.model,
|
|
156
|
+
durationMs: data.durationMs ?? data.modelCallDurationMs ?? nested.durationMs,
|
|
157
|
+
// The CLI envelope carries no `source`, and an agent-scoped failure never
|
|
158
|
+
// reaches here: `handleModelCallFailure` returns on `logAgentScopedFailure`
|
|
159
|
+
// first. An absent source on this shape is the root session's own call.
|
|
160
|
+
source: typeof data.source === 'string' ? data.source : 'top_level',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
122
163
|
function isRecoverableModelCallFailure(data) {
|
|
123
164
|
if (!data)
|
|
124
165
|
return false;
|
|
@@ -906,72 +947,6 @@ function parseMcpConfig(configPath, mcpServerWorkingDirectory, terminalLogLevel)
|
|
|
906
947
|
return null;
|
|
907
948
|
}
|
|
908
949
|
}
|
|
909
|
-
const ENV_REFERENCE = /^\$\{([A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*)\}$/;
|
|
910
|
-
/**
|
|
911
|
-
* Resolves ${NAME} and ${NAME|FALLBACK|...}, taking the first variable that is set.
|
|
912
|
-
*
|
|
913
|
-
* The fallback chain exists because there is no single conventional name for a GitHub
|
|
914
|
-
* token: the Copilot CLI itself reads COPILOT_GITHUB_TOKEN, then GH_TOKEN, then
|
|
915
|
-
* GITHUB_TOKEN. A default that insisted on one spelling would silently ship an
|
|
916
|
-
* unauthenticated server in any environment that chose a different one.
|
|
917
|
-
*/
|
|
918
|
-
function resolveMcpEnvironmentValue(value) {
|
|
919
|
-
const match = ENV_REFERENCE.exec(value);
|
|
920
|
-
if (!match)
|
|
921
|
-
return value;
|
|
922
|
-
for (const name of match[1].split('|')) {
|
|
923
|
-
const resolved = process.env[name];
|
|
924
|
-
if (resolved !== undefined && resolved !== '')
|
|
925
|
-
return resolved;
|
|
926
|
-
}
|
|
927
|
-
return undefined;
|
|
928
|
-
}
|
|
929
|
-
function resolveMcpEnvironmentRecord(values) {
|
|
930
|
-
if (values === undefined)
|
|
931
|
-
return undefined;
|
|
932
|
-
const resolved = {};
|
|
933
|
-
for (const [key, value] of Object.entries(values)) {
|
|
934
|
-
const environmentValue = resolveMcpEnvironmentValue(value);
|
|
935
|
-
if (environmentValue !== undefined)
|
|
936
|
-
resolved[key] = environmentValue;
|
|
937
|
-
}
|
|
938
|
-
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
939
|
-
}
|
|
940
|
-
// Embedded references, so `Bearer ${A|B}` resolves rather than only a bare `${A|B}`. The
|
|
941
|
-
// fallback alternatives must be part of the pattern: a reference matcher without them finds
|
|
942
|
-
// nothing in `${A|B}`, leaves the value untouched, and emits the placeholder as a literal
|
|
943
|
-
// header — an unauthenticated server that reports no error and simply returns no tools.
|
|
944
|
-
const HEADER_ENV_REFERENCE = /\$\{([A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*)\}/g;
|
|
945
|
-
function resolveMcpHeaders(headers, terminalLogLevel) {
|
|
946
|
-
if (headers === undefined)
|
|
947
|
-
return undefined;
|
|
948
|
-
const resolved = {};
|
|
949
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
950
|
-
const references = [...value.matchAll(HEADER_ENV_REFERENCE)];
|
|
951
|
-
let resolvedValue = value;
|
|
952
|
-
let complete = true;
|
|
953
|
-
for (const reference of references) {
|
|
954
|
-
const names = reference[1].split('|');
|
|
955
|
-
const environmentValue = names
|
|
956
|
-
.map((name) => process.env[name])
|
|
957
|
-
.find((candidate) => candidate !== undefined && candidate !== '');
|
|
958
|
-
if (environmentValue === undefined) {
|
|
959
|
-
// Dropping the header is deliberate — a missing credential must not abort a run — but
|
|
960
|
-
// it is invisible at the server, which just answers with nothing.
|
|
961
|
-
try {
|
|
962
|
-
writeDiagnosticStderr('warning', `[SdkBackend] MCP header ${key} dropped: none of ${names.join(', ')} is set\n`, terminalLogLevel);
|
|
963
|
-
}
|
|
964
|
-
catch { /* diagnostics must not break configuration */ }
|
|
965
|
-
complete = false;
|
|
966
|
-
break;
|
|
967
|
-
}
|
|
968
|
-
resolvedValue = resolvedValue.replace(reference[0], environmentValue);
|
|
969
|
-
}
|
|
970
|
-
if (complete)
|
|
971
|
-
resolved[key] = resolvedValue;
|
|
972
|
-
}
|
|
973
|
-
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
974
|
-
}
|
|
975
950
|
function toSdkMcpServers(servers, terminalLogLevel) {
|
|
976
951
|
if (servers === undefined || servers === false)
|
|
977
952
|
return servers;
|
|
@@ -981,14 +956,19 @@ function toSdkMcpServers(servers, terminalLogLevel) {
|
|
|
981
956
|
if (!parsed)
|
|
982
957
|
continue;
|
|
983
958
|
if ('url' in parsed) {
|
|
984
|
-
const headers = resolveMcpHeaders(parsed.headers,
|
|
959
|
+
const headers = (0, mcp_env_1.resolveMcpHeaders)(parsed.headers, ({ header, names }) => {
|
|
960
|
+
try {
|
|
961
|
+
writeDiagnosticStderr('warning', `[SdkBackend] MCP header ${header} dropped: none of ${names.join(', ')} is set\n`, terminalLogLevel);
|
|
962
|
+
}
|
|
963
|
+
catch { /* diagnostics must not break configuration */ }
|
|
964
|
+
});
|
|
985
965
|
converted[name] = {
|
|
986
966
|
...parsed,
|
|
987
967
|
...(headers !== undefined ? { headers } : { headers: undefined }),
|
|
988
968
|
};
|
|
989
969
|
}
|
|
990
970
|
else {
|
|
991
|
-
const env = resolveMcpEnvironmentRecord(parsed.env);
|
|
971
|
+
const env = (0, mcp_env_1.resolveMcpEnvironmentRecord)(parsed.env);
|
|
992
972
|
converted[name] = {
|
|
993
973
|
...parsed,
|
|
994
974
|
...(env !== undefined ? { env } : { env: undefined }),
|
|
@@ -1115,6 +1095,13 @@ class SdkSession {
|
|
|
1115
1095
|
deferredIdle = false;
|
|
1116
1096
|
parentUsage;
|
|
1117
1097
|
turnSettled = false;
|
|
1098
|
+
/**
|
|
1099
|
+
* Resolvers parked by `continueSession`, settled by the *next* idle/error.
|
|
1100
|
+
* `continueSession` owns the wait for its own turn so `agent()` does not have
|
|
1101
|
+
* to re-register idle/error handlers it cannot remove (the session interface
|
|
1102
|
+
* has no `off`).
|
|
1103
|
+
*/
|
|
1104
|
+
settleWaiters = [];
|
|
1118
1105
|
aborted = false;
|
|
1119
1106
|
sessionId;
|
|
1120
1107
|
originalPrompt;
|
|
@@ -1227,6 +1214,9 @@ class SdkSession {
|
|
|
1227
1214
|
this.emitError(new Error('Session is aborted'));
|
|
1228
1215
|
return;
|
|
1229
1216
|
}
|
|
1217
|
+
// A parked `continueSession` belongs to the previous turn. This is a fresh
|
|
1218
|
+
// lifecycle; leaving it parked would hang its caller forever.
|
|
1219
|
+
this.rejectContinuation(new Error('Session was restarted before the continuation settled'));
|
|
1230
1220
|
this.originalPrompt = prompt;
|
|
1231
1221
|
this.sessionId = undefined;
|
|
1232
1222
|
this.sdkSessionConfigFactory = undefined;
|
|
@@ -1262,6 +1252,67 @@ class SdkSession {
|
|
|
1262
1252
|
this.fail(err instanceof Error ? err : new Error(String(err)), 'session.run');
|
|
1263
1253
|
});
|
|
1264
1254
|
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Append one more user turn to the live session and resolve when it settles.
|
|
1257
|
+
*
|
|
1258
|
+
* This is the warm counterpart to `send()`: it never creates or resumes a
|
|
1259
|
+
* session, so whatever the first turn built stays intact. `agent()` uses it to
|
|
1260
|
+
* ask for a declared output the first turn forgot.
|
|
1261
|
+
*
|
|
1262
|
+
* Rejects when the session is aborted, the handle is gone, a compaction owns
|
|
1263
|
+
* the session, the transport refuses the turn, or the turn itself fails. Every
|
|
1264
|
+
* rejection means "no continuation happened"; callers report the miss rather
|
|
1265
|
+
* than falling back to a cold restart.
|
|
1266
|
+
*/
|
|
1267
|
+
async continueSession(prompt) {
|
|
1268
|
+
if (this.aborted)
|
|
1269
|
+
throw new Error('Session is aborted');
|
|
1270
|
+
// A recovery in flight is already resuming this session; let it finish so we
|
|
1271
|
+
// send the continuation over whatever handle it installs.
|
|
1272
|
+
if (this.recoveryInProgress)
|
|
1273
|
+
await this.recoveryInProgress;
|
|
1274
|
+
if (this.aborted)
|
|
1275
|
+
throw new Error('Session is aborted');
|
|
1276
|
+
if (this.activeCompactionEventId !== undefined) {
|
|
1277
|
+
throw new Error('Session is compacting; cannot continue the turn');
|
|
1278
|
+
}
|
|
1279
|
+
const handle = this._sdkSession;
|
|
1280
|
+
if (!handle)
|
|
1281
|
+
throw new Error('Session handle is gone; cannot continue the turn');
|
|
1282
|
+
// Reopen the turn so idle, the heartbeat and `fail()` treat the
|
|
1283
|
+
// continuation as live rather than as an already-settled turn.
|
|
1284
|
+
this.turnSettled = false;
|
|
1285
|
+
this.deferredIdle = false;
|
|
1286
|
+
this._turnText.clear();
|
|
1287
|
+
this.lastForwardProgressAt = Date.now();
|
|
1288
|
+
this.lastForwardProgressType = 'continuation-prompt';
|
|
1289
|
+
const waiter = { resolve: () => undefined, reject: () => undefined };
|
|
1290
|
+
const settled = new Promise((resolve, reject) => {
|
|
1291
|
+
waiter.resolve = resolve;
|
|
1292
|
+
waiter.reject = reject;
|
|
1293
|
+
});
|
|
1294
|
+
this.settleWaiters.push(waiter);
|
|
1295
|
+
// The attempt deadline is absolute, so this re-arms the remainder of it
|
|
1296
|
+
// rather than granting the continuation a fresh budget.
|
|
1297
|
+
this.rearmHardTimeout(handle);
|
|
1298
|
+
this.resetHeartbeat();
|
|
1299
|
+
try {
|
|
1300
|
+
await handle.rpc.sendMessages({
|
|
1301
|
+
messages: [{ prompt, displayPrompt: prompt }],
|
|
1302
|
+
wait: false,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
catch (error) {
|
|
1306
|
+
this.removeContinuationWaiter(waiter);
|
|
1307
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1308
|
+
}
|
|
1309
|
+
await settled;
|
|
1310
|
+
}
|
|
1311
|
+
removeContinuationWaiter(waiter) {
|
|
1312
|
+
const index = this.settleWaiters.indexOf(waiter);
|
|
1313
|
+
if (index >= 0)
|
|
1314
|
+
this.settleWaiters.splice(index, 1);
|
|
1315
|
+
}
|
|
1265
1316
|
/**
|
|
1266
1317
|
* Internal async entrypoint that creates the client, session, wires events,
|
|
1267
1318
|
* sends the prompt, and handles lifecycle.
|
|
@@ -1896,7 +1947,7 @@ class SdkSession {
|
|
|
1896
1947
|
});
|
|
1897
1948
|
}
|
|
1898
1949
|
scheduleRecovery(failedHandle, error, data, eventId, options = {}) {
|
|
1899
|
-
const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
|
|
1950
|
+
const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
|
|
1900
1951
|
if (!policy) {
|
|
1901
1952
|
const eventType = data?.failureKind === 'session-progress-timeout'
|
|
1902
1953
|
? 'session.progress_timeout'
|
|
@@ -1928,7 +1979,7 @@ class SdkSession {
|
|
|
1928
1979
|
}
|
|
1929
1980
|
this.recoveryStartedAt ??= Date.now();
|
|
1930
1981
|
this.recoveryDeadlineMs ??= Math.min(this.sessionDeadlineMs ?? Number.POSITIVE_INFINITY, this.recoveryStartedAt + policy.budgetMs);
|
|
1931
|
-
const delayMs = recoveryDelayMs(policy, this.recoveryContinuation);
|
|
1982
|
+
const delayMs = (0, session_recovery_policy_1.recoveryDelayMs)(policy, this.recoveryContinuation);
|
|
1932
1983
|
this.emitRecovery('scheduled', policy, this.recoveryContinuation + 1, sessionId, {
|
|
1933
1984
|
delayMs,
|
|
1934
1985
|
...this.recoveryMetadata(data),
|
|
@@ -1972,49 +2023,126 @@ class SdkSession {
|
|
|
1972
2023
|
this.exhaustRecovery(policy, sessionId, originalError, 'balanced root history has no terminal completion evidence', data);
|
|
1973
2024
|
return;
|
|
1974
2025
|
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
//
|
|
2026
|
+
// A handshake that fails reconnects and nudges again rather than ending
|
|
2027
|
+
// the turn. Every resume passes `continuePendingWork: false` and every
|
|
2028
|
+
// continuation sends an empty message list, so no attempt replays a turn
|
|
2029
|
+
// or re-issues a tool call: the session is left exactly where the failed
|
|
2030
|
+
// turn stopped, and the next attempt resumes that same state. The
|
|
2031
|
+
// continuation count and the recovery budget are what bound the loop.
|
|
2032
|
+
for (;;) {
|
|
2033
|
+
this.recoveryContinuation += 1;
|
|
2034
|
+
this.emitRecovery('resuming', policy, this.recoveryContinuation, sessionId, {
|
|
2035
|
+
...this.recoveryMetadata(data),
|
|
2036
|
+
});
|
|
2037
|
+
try {
|
|
2038
|
+
await this.attemptSameSessionResume(failedHandle, policy, before, data);
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
catch (attemptError) {
|
|
2042
|
+
if (this.aborted || this.turnSettled)
|
|
2043
|
+
return;
|
|
2044
|
+
const failed = toResumeAttemptError(attemptError, 'fence');
|
|
2045
|
+
if (failed.fatal
|
|
2046
|
+
|| this.recoveryContinuation >= policy.maxContinuations
|
|
2047
|
+
|| (this.recoveryDeadlineMs !== undefined && Date.now() >= this.recoveryDeadlineMs)) {
|
|
2048
|
+
this.exhaustRecovery(policy, sessionId, originalError, failed.message, data, failed.stage);
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
const retryDelayMs = (0, session_recovery_policy_1.recoveryDelayMs)(policy, this.recoveryContinuation);
|
|
2052
|
+
this.emitRecovery('scheduled', policy, this.recoveryContinuation + 1, sessionId, {
|
|
2053
|
+
delayMs: retryDelayMs,
|
|
2054
|
+
...this.recoveryMetadata(data),
|
|
2055
|
+
});
|
|
2056
|
+
try {
|
|
2057
|
+
await this.waitWithinRecoveryDeadline(retryDelayMs);
|
|
2058
|
+
}
|
|
2059
|
+
catch {
|
|
2060
|
+
// The budget, not the handshake, is what ended recovery, so the
|
|
2061
|
+
// reason worth reporting is the handshake's own failure.
|
|
2062
|
+
this.exhaustRecovery(policy, sessionId, originalError, failed.message, data, failed.stage);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
catch (recoveryError) {
|
|
2069
|
+
if (this.aborted || this.turnSettled)
|
|
2070
|
+
return;
|
|
2071
|
+
// Only the pre-flight reaches here: the initial recovery wait and the
|
|
2072
|
+
// history audit that precedes the first handshake. A failing handshake is
|
|
2073
|
+
// handled inside the loop, which retries it within the same bounds.
|
|
2074
|
+
const normalized = recoveryError instanceof Error
|
|
2075
|
+
? recoveryError
|
|
2076
|
+
: new Error(String(recoveryError));
|
|
2077
|
+
this.exhaustRecovery(policy, sessionId, originalError, normalized.message, data);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* One reconnect handshake: fence the failed handle, stand up a fresh client,
|
|
2082
|
+
* resume the same session without replaying any turn, restore the resumed
|
|
2083
|
+
* session's configuration, and nudge it with an empty message.
|
|
2084
|
+
*
|
|
2085
|
+
* Repeating this is safe by construction. `continuePendingWork: false` plus an
|
|
2086
|
+
* empty-message send appends nothing to the transcript, so a handshake that
|
|
2087
|
+
* fails at any step leaves the session where the failed turn stopped and the
|
|
2088
|
+
* next attempt resumes that same state.
|
|
2089
|
+
*
|
|
2090
|
+
* Returns once the continuation was sent, or once the abandoned turn turns out
|
|
2091
|
+
* to have completed. Throws otherwise, after tearing down whatever it built,
|
|
2092
|
+
* so the caller can retry from a clean fence.
|
|
2093
|
+
*/
|
|
2094
|
+
async attemptSameSessionResume(failedHandle, policy, before, data) {
|
|
2095
|
+
const sessionId = this.sessionId;
|
|
2096
|
+
if (!sessionId)
|
|
2097
|
+
return;
|
|
2098
|
+
let stage = 'fence';
|
|
2099
|
+
try {
|
|
2100
|
+
// Fence synchronously before any asynchronous cleanup. Target whatever
|
|
2101
|
+
// handle is live: after a resume that succeeded through the handshake and
|
|
2102
|
+
// then failed on the nudge, that is the resumed handle, not the original.
|
|
2103
|
+
const handleToFence = this._sdkSession ?? failedHandle;
|
|
1980
2104
|
this._sdkSession = null;
|
|
1981
2105
|
this.handleGeneration += 1;
|
|
1982
2106
|
this.activeCompactionEventId = undefined;
|
|
1983
2107
|
this.suspendHeartbeat();
|
|
1984
|
-
await
|
|
2108
|
+
await handleToFence.disconnect().catch(() => undefined);
|
|
1985
2109
|
const oldClient = this._client;
|
|
1986
2110
|
this._client = null;
|
|
1987
2111
|
if (oldClient) {
|
|
1988
2112
|
await boundedCleanup(oldClient.stop()).catch(() => undefined);
|
|
1989
2113
|
await oldClient.forceStop().catch(() => undefined);
|
|
1990
2114
|
}
|
|
2115
|
+
stage = 'client';
|
|
1991
2116
|
const clientFactory = this.clientFactory;
|
|
1992
2117
|
const configFactory = this.sdkSessionConfigFactory;
|
|
1993
2118
|
if (!clientFactory || !configFactory) {
|
|
1994
|
-
throw new
|
|
2119
|
+
throw new SessionResumeAttemptError(stage, 'SDK recovery configuration is unavailable', true);
|
|
1995
2120
|
}
|
|
1996
2121
|
const client = clientFactory();
|
|
1997
2122
|
this._client = client;
|
|
1998
2123
|
await client.start();
|
|
1999
2124
|
const { sessionId: _createSessionId, ...resumeConfig } = configFactory(client);
|
|
2125
|
+
stage = 'resume';
|
|
2000
2126
|
const resumed = await client.resumeSession(sessionId, {
|
|
2001
2127
|
...resumeConfig,
|
|
2002
2128
|
continuePendingWork: false,
|
|
2003
2129
|
});
|
|
2004
2130
|
if (resumed.sessionId !== sessionId) {
|
|
2005
2131
|
await resumed.disconnect().catch(() => undefined);
|
|
2006
|
-
throw new
|
|
2132
|
+
throw new SessionResumeAttemptError(stage, `SDK resumed unexpected session '${resumed.sessionId}' instead of '${sessionId}'`, true);
|
|
2007
2133
|
}
|
|
2008
2134
|
if (this.aborted || this.turnSettled) {
|
|
2009
2135
|
await resumed.disconnect().catch(() => undefined);
|
|
2010
2136
|
return;
|
|
2011
2137
|
}
|
|
2138
|
+
stage = 'configuration';
|
|
2012
2139
|
this._sdkSession = resumed;
|
|
2013
2140
|
this.handleGeneration += 1;
|
|
2014
2141
|
this.resetStateAfterResume();
|
|
2015
2142
|
this.rearmHardTimeout(resumed);
|
|
2016
2143
|
await this.applyResumedSessionConfiguration(resumed);
|
|
2017
2144
|
this._wireEvents(resumed);
|
|
2145
|
+
stage = 'audit';
|
|
2018
2146
|
const afterResume = await this.auditRecoveryHistory(resumed, sessionId);
|
|
2019
2147
|
if (afterResume.unmatchedRootTurns === 0) {
|
|
2020
2148
|
const taskCompletedDuringHandoff = afterResume.latestRootTurnHasTerminalResponse
|
|
@@ -2029,6 +2157,7 @@ class SdkSession {
|
|
|
2029
2157
|
// Disconnect can close the abandoned turn without completing the task.
|
|
2030
2158
|
// Keep the continuation path active rather than synthesizing false success.
|
|
2031
2159
|
}
|
|
2160
|
+
stage = 'continuation';
|
|
2032
2161
|
this.lastForwardProgressAt = Date.now();
|
|
2033
2162
|
this.lastForwardProgressType = 'continuation-sent';
|
|
2034
2163
|
await resumed.rpc.sendMessages({ messages: [], wait: false });
|
|
@@ -2037,20 +2166,29 @@ class SdkSession {
|
|
|
2037
2166
|
...this.recoveryMetadata(data),
|
|
2038
2167
|
});
|
|
2039
2168
|
}
|
|
2040
|
-
catch (
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2169
|
+
catch (error) {
|
|
2170
|
+
await this.tearDownFailedResume();
|
|
2171
|
+
throw toResumeAttemptError(error, stage);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Undo a failed handshake so the next attempt fences from the state it would
|
|
2176
|
+
* have started from. The attempt may already have reached the server, so both
|
|
2177
|
+
* the resumed handle and the client holding it come down: a lingering client
|
|
2178
|
+
* could make the SDK refuse the next resume as already active.
|
|
2179
|
+
*/
|
|
2180
|
+
async tearDownFailedResume() {
|
|
2181
|
+
const handle = this._sdkSession;
|
|
2182
|
+
this._sdkSession = null;
|
|
2183
|
+
this.handleGeneration += 1;
|
|
2184
|
+
this.suspendHeartbeat();
|
|
2185
|
+
if (handle)
|
|
2186
|
+
await handle.disconnect().catch(() => undefined);
|
|
2187
|
+
const client = this._client;
|
|
2188
|
+
this._client = null;
|
|
2189
|
+
if (client) {
|
|
2190
|
+
await boundedCleanup(client.stop()).catch(() => undefined);
|
|
2191
|
+
await client.forceStop().catch(() => undefined);
|
|
2054
2192
|
}
|
|
2055
2193
|
}
|
|
2056
2194
|
async auditRecoveryHistory(handle, sessionId) {
|
|
@@ -2236,9 +2374,15 @@ class SdkSession {
|
|
|
2236
2374
|
...fields,
|
|
2237
2375
|
});
|
|
2238
2376
|
}
|
|
2239
|
-
|
|
2377
|
+
/**
|
|
2378
|
+
* `stage` names the reconnect step whose failure ended recovery, when the
|
|
2379
|
+
* end came from a handshake rather than from the budget alone. The `reason`
|
|
2380
|
+
* alone ("Connection is disposed.") does not say which RPC produced it.
|
|
2381
|
+
*/
|
|
2382
|
+
exhaustRecovery(policy, sessionId, cause, reason, data, stage) {
|
|
2240
2383
|
this.emitRecovery('exhausted', policy, this.recoveryContinuation, sessionId, {
|
|
2241
2384
|
reason,
|
|
2385
|
+
...(stage !== undefined ? { stage } : {}),
|
|
2242
2386
|
...this.recoveryMetadata(data),
|
|
2243
2387
|
});
|
|
2244
2388
|
this.fail(new types_1.SessionRecoveryExhaustedError(`Session recovery exhausted after ${this.recoveryContinuation} continuation(s): ${reason}`, { cause }), 'session.recovery_exhausted', data);
|
|
@@ -2258,6 +2402,7 @@ class SdkSession {
|
|
|
2258
2402
|
return;
|
|
2259
2403
|
const error = new Error('Session aborted');
|
|
2260
2404
|
this.clearTimers();
|
|
2405
|
+
this.rejectContinuation(error);
|
|
2261
2406
|
this.emitError(error);
|
|
2262
2407
|
this.aborted = true;
|
|
2263
2408
|
await this._cleanup();
|
|
@@ -2312,22 +2457,48 @@ class SdkSession {
|
|
|
2312
2457
|
settleIdle() {
|
|
2313
2458
|
if (this.aborted || this.turnSettled)
|
|
2314
2459
|
return;
|
|
2315
|
-
|
|
2316
|
-
|
|
2460
|
+
// A parked waiter means this idle closes a declared-output continuation
|
|
2461
|
+
// rather than the attempt's first turn, so the recovery bookkeeping (and
|
|
2462
|
+
// its `recovered` event) does not apply to it.
|
|
2463
|
+
const isContinuation = this.settleWaiters.length > 0;
|
|
2464
|
+
const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
|
|
2465
|
+
if (!isContinuation && policy && this.recoveryContinuation > 0 && this.sessionId) {
|
|
2317
2466
|
this.emitRecovery('recovered', policy, this.recoveryContinuation, this.sessionId);
|
|
2318
2467
|
}
|
|
2319
2468
|
this.turnSettled = true;
|
|
2320
2469
|
this._turnText.clear();
|
|
2321
2470
|
this.clearTimers();
|
|
2322
2471
|
this.emit('idle');
|
|
2323
|
-
|
|
2472
|
+
this.settleContinuation();
|
|
2473
|
+
// The handle is deliberately left alive: `agent()`'s declared-output guard
|
|
2474
|
+
// continues the same session when the turn ended without the artifact, and
|
|
2475
|
+
// it cannot do that against a torn-down client. Teardown is owned by
|
|
2476
|
+
// `abort()`, which the agent node always calls in its `finally`.
|
|
2477
|
+
}
|
|
2478
|
+
settleContinuation() {
|
|
2479
|
+
const waiters = this.settleWaiters;
|
|
2480
|
+
if (waiters.length === 0)
|
|
2481
|
+
return;
|
|
2482
|
+
this.settleWaiters = [];
|
|
2483
|
+
for (const waiter of waiters)
|
|
2484
|
+
waiter.resolve();
|
|
2485
|
+
}
|
|
2486
|
+
rejectContinuation(error) {
|
|
2487
|
+
const waiters = this.settleWaiters;
|
|
2488
|
+
if (waiters.length === 0)
|
|
2489
|
+
return;
|
|
2490
|
+
this.settleWaiters = [];
|
|
2491
|
+
for (const waiter of waiters)
|
|
2492
|
+
waiter.reject(error);
|
|
2324
2493
|
}
|
|
2325
2494
|
handleModelCallFailure(sdkSession, e) {
|
|
2326
2495
|
if (this._sdkSession !== sdkSession || this.aborted)
|
|
2327
2496
|
return;
|
|
2328
2497
|
if (this.logAgentScopedFailure(e))
|
|
2329
2498
|
return;
|
|
2330
|
-
|
|
2499
|
+
// The payload may be the flat SDK shape or the CLI's nested envelope; see
|
|
2500
|
+
// `normalizeModelCallFailure`. Everything below reads the normalized view.
|
|
2501
|
+
const data = normalizeModelCallFailure(e.data);
|
|
2331
2502
|
const detail = typeof data?.errorMessage === 'string'
|
|
2332
2503
|
? data.errorMessage
|
|
2333
2504
|
: typeof data?.errorCode === 'string'
|
|
@@ -2377,6 +2548,7 @@ class SdkSession {
|
|
|
2377
2548
|
this.activeCompactionEventId = undefined;
|
|
2378
2549
|
this.turnSettled = true;
|
|
2379
2550
|
this.clearTimers();
|
|
2551
|
+
this.rejectContinuation(err);
|
|
2380
2552
|
try {
|
|
2381
2553
|
this.writeStderr('error', `[SdkBackend] ${eventType}: ${this.serializeEventDataCompact(eventType, data)}\n`);
|
|
2382
2554
|
}
|
|
@@ -2446,7 +2618,7 @@ class SdkSession {
|
|
|
2446
2618
|
if (!e.agentId && this.nativeRetryGraceTimer) {
|
|
2447
2619
|
clearTimeout(this.nativeRetryGraceTimer);
|
|
2448
2620
|
this.nativeRetryGraceTimer = null;
|
|
2449
|
-
const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
|
|
2621
|
+
const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
|
|
2450
2622
|
if (policy && this.sessionId) {
|
|
2451
2623
|
this.emitRecovery('native-retry', policy, this.recoveryContinuation, this.sessionId);
|
|
2452
2624
|
}
|
|
@@ -2687,16 +2859,23 @@ class SdkSession {
|
|
|
2687
2859
|
inactivityMs,
|
|
2688
2860
|
lastProgressType: this.lastForwardProgressType,
|
|
2689
2861
|
};
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
const policy = effectiveSessionRecoveryPolicy(this.config.sessionRecovery);
|
|
2862
|
+
// Resolve the policy before the in-flight test: a node that opted into
|
|
2863
|
+
// recovery prefers the warm continuation over failing outright, even with
|
|
2864
|
+
// a tool call outstanding. The resume does not replay that call, so a
|
|
2865
|
+
// genuinely running subagent read is abandoned and re-issued by the model.
|
|
2866
|
+
// For the read-only work recovery is opted into for, that is the right
|
|
2867
|
+
// trade against failing the node; a mutating node opts out of recovery.
|
|
2868
|
+
const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
|
|
2869
|
+
const hasPendingWork = this.activeToolCalls.size > 0 || this.pendingExternalRequests.size > 0;
|
|
2699
2870
|
if (!policy) {
|
|
2871
|
+
if (hasPendingWork) {
|
|
2872
|
+
this.fail(error, 'session.progress_timeout.pending_work', {
|
|
2873
|
+
...data,
|
|
2874
|
+
activeToolCalls: this.activeToolCalls.size,
|
|
2875
|
+
pendingExternalRequests: this.pendingExternalRequests.size,
|
|
2876
|
+
});
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2700
2879
|
this.fail(error, 'session.progress_timeout', data);
|
|
2701
2880
|
return;
|
|
2702
2881
|
}
|