@shardflux/sdk 0.12.0 → 0.13.1

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.
@@ -80,6 +80,45 @@ export interface paths {
80
80
  * `503 service_unavailable` (`no_execution_host`: no host with
81
81
  * protocol feature `file_first` has room; retry), secret resolution
82
82
  * errors as for processful starts.
83
+ *
84
+ * Burst execution (contracts §33, not public yet): `burst: always` runs
85
+ * the command on a larger, short-lived burst VM on the workspace's
86
+ * host over a copy of the workspace disk and applies its file changes
87
+ * back when it exits. The session is read, streamed and attached like
88
+ * any other; it carries `burst` (BurstSummary) and cannot be signalled
89
+ * or canceled (`409 conflict`, `details.reason burst_not_supported`).
90
+ * Refusals: `422 validation_failed` with `details.reason`
91
+ * `burst_mode_not_supported` (`auto`), `burst_not_supported` (stdin),
92
+ * `burst_size_exceeds_plan` (`burst_vcpus`/`burst_memory_mib` above the
93
+ * plan's per-workspace ceilings; `details.field`, `details.limit`);
94
+ * `409 burst_unavailable` with `details.reason` `not_available` (no
95
+ * entitlement `policy.burst_exec`, or the host lacks protocol feature
96
+ * `burst_exec`), `layout_unsupported`, `shared_volumes`,
97
+ * `host_capacity`, `fence_not_drained`, `workspace_fenced`,
98
+ * `apply_pending`, `park_failed`, `workspace_resumed`, `interrupted`;
99
+ * `409 burst_apply_failed` (`details.reason` `disk_full` or
100
+ * `apply_failed`, `details.applied_entries`, `details.pending_entries`;
101
+ * the workspace refuses writes until a retry with the same
102
+ * `session_id` completes the apply). A refusal the host gives within
103
+ * the first seconds is the start's answer; a later one ends the
104
+ * session (`failed_to_start` for `burst_unavailable`, `lost` for
105
+ * `burst_apply_failed`), is recorded in `burst.error`, and is the
106
+ * output stream's final `error` event. A retry with the same
107
+ * `session_id` replays the recorded outcome (the host journals the
108
+ * burst by the exec's operation id) or resumes an unfinished apply.
109
+ *
110
+ * Start and follow in one request: a processful start sent with
111
+ * `Accept: application/x-ndjson` (or `text/event-stream`) answers 200
112
+ * with the session's output stream, as `GET .../output?follow=true`
113
+ * from offset 0 (output events, `exit`, a final `error`), and
114
+ * `X-Exec-Session-Id` names the session. The stream slot is taken
115
+ * before the command starts (`503 service_unavailable` when the
116
+ * gateway has no stream slot: nothing ran). Headers are sent as soon
117
+ * as the command started, before any output. A `failed_to_start`
118
+ * session, a refusal and a file-first execution keep their JSON
119
+ * answers. A replayed `session_id` streams the existing session and
120
+ * never runs it again; after a dropped stream, reconnect with
121
+ * `GET .../output` from the byte offsets already read.
83
122
  */
84
123
  post: operations["execStart"];
85
124
  delete?: never;
@@ -152,7 +191,7 @@ export interface paths {
152
191
  * Client messages are JSON `ExecClientMessage`. Close codes: 1000 after
153
192
  * the `exit` event, 1001 on gateway shutdown (reconnect with offsets),
154
193
  * 4401 unauthenticated, 4403 forbidden, 4409 stale epoch / workspace
155
- * busy / workspace not running, 4410 workspace gone, 4429 rate limited,
194
+ * busy / workspace not running, 4410 workspace gone, 4429 rate limited / quota exceeded,
156
195
  * 4500/4503/4504 internal/unavailable/timeout (4000 + HTTP status; see
157
196
  * "WebSocket close codes" above; the close reason is compact JSON with
158
197
  * the error `code`). A refused upgrade from an allowed Origin is
@@ -225,7 +264,20 @@ export interface paths {
225
264
  };
226
265
  get?: never;
227
266
  put?: never;
228
- /** SIGTERM the process group, SIGKILL after grace */
267
+ /**
268
+ * Stop the command and every process it started (SIGTERM, SIGKILL after grace)
269
+ * @description Sends SIGTERM to every process the command started, background and
270
+ * setsid processes included, and SIGKILL after `grace_ms` (0 to 60000;
271
+ * 0 or omitted is 5000). Answers when they have all exited: at once when
272
+ * SIGTERM ends them, otherwise after the grace and the SIGKILL. A lost
273
+ * command is stopped the same way; a command that already exited keeps
274
+ * its result and what it left running is stopped. `503
275
+ * dependency_unavailable` (retryable) while a process cannot exit yet.
276
+ * Template versions before python-node-browser 13, ubuntu-24.04 4,
277
+ * ubuntu-22.04 2 and debian-12 2 signal the command's process group and
278
+ * return an ended session as it is. A `grace_ms` outside the range is `422 validation_failed`
279
+ * (`details.field` `grace_ms`, `details.minimum`, `details.maximum`).
280
+ */
229
281
  post: operations["execCancel"];
230
282
  delete?: never;
231
283
  options?: never;
@@ -293,7 +345,11 @@ export interface paths {
293
345
  get: operations["ptyGet"];
294
346
  put?: never;
295
347
  post?: never;
296
- /** Close (SIGHUP, then SIGKILL after grace) */
348
+ /**
349
+ * Close (SIGHUP, then SIGKILL after grace)
350
+ * @description Sends SIGHUP to the session's process group and SIGKILL 2 s later
351
+ * if it is still running; answers once it has ended.
352
+ */
297
353
  delete: operations["ptyClose"];
298
354
  options?: never;
299
355
  head?: never;
@@ -744,8 +800,9 @@ export interface paths {
744
800
  * @description `idle_since` = max(`last_tool_at`, `last_attach_at`, `keepalive_until`,
745
801
  * `running_since`, the work horizon of running exec commands) (null when
746
802
  * none is known; in the future while a keepalive or a running command is
747
- * active). A running exec command counts as work until min(its start +
748
- * `IDLE_COMMAND_MAX_SECONDS`, its start + `timeout_ms`), and its end is
803
+ * active). A running exec command counts as work until it ends or its
804
+ * timeout passes: its start + `timeout_ms` when it has one, else its
805
+ * start + `IDLE_COMMAND_MAX_SECONDS` (1 hour by default). Its end is
749
806
  * activity (contracts §20.6): an end seen without a client call is
750
807
  * recorded in `last_attach_at`, which is work but not a tool call.
751
808
  * `suspend_at` = `idle_since` + `idle_minutes` when
@@ -823,7 +880,7 @@ export type webhooks = Record<string, never>;
823
880
  export interface components {
824
881
  schemas: {
825
882
  /** @enum {string} */
826
- ErrorCode: "bad_request" | "unauthenticated" | "forbidden" | "not_found" | "conflict" | "stale_epoch" | "workspace_not_running" | "validation_failed" | "payload_too_large" | "range_not_satisfiable" | "rate_limited" | "capacity_pending" | "dependency_unavailable" | "timeout" | "internal_error" | "service_unavailable" | "workspace_busy";
883
+ ErrorCode: "bad_request" | "unauthenticated" | "forbidden" | "not_found" | "conflict" | "stale_epoch" | "workspace_not_running" | "validation_failed" | "payload_too_large" | "range_not_satisfiable" | "rate_limited" | "capacity_pending" | "dependency_unavailable" | "timeout" | "internal_error" | "service_unavailable" | "workspace_busy" | "burst_unavailable" | "burst_apply_failed" | "quota_exceeded";
827
884
  ErrorBody: {
828
885
  error: {
829
886
  code: components["schemas"]["ErrorCode"];
@@ -872,7 +929,10 @@ export interface components {
872
929
  stdin?: string;
873
930
  /** Format: int64 */
874
931
  timeout_ms?: number;
875
- /** Format: int64 */
932
+ /**
933
+ * Format: int64
934
+ * @description Time between the SIGTERM and the SIGKILL when timeout_ms ends the command; 0 or omitted is 5000.
935
+ */
876
936
  kill_grace_ms?: number;
877
937
  secret_refs?: components["schemas"]["SecretRefs"];
878
938
  /** @description File-first workspaces (required there, refused otherwise) — the execution's idempotency key. */
@@ -883,6 +943,16 @@ export interface components {
883
943
  * @default 1048576
884
944
  */
885
945
  output_limit_bytes?: number;
946
+ /**
947
+ * @description Burst execution (contracts §33): `always` runs this run-to-completion command on a larger, short-lived burst VM and applies its file changes back. Needs entitlement `policy.burst_exec` and a layered processful workspace; not with stdin. `auto` is reserved (422 validation_failed, details.reason burst_mode_not_supported).
948
+ * @default never
949
+ * @enum {string}
950
+ */
951
+ burst?: "never" | "always";
952
+ /** @description The burst VM's vCPUs (burst always only; default the host's, 8), at most the plan's workspace CPU ceiling. */
953
+ burst_vcpus?: number;
954
+ /** @description The burst VM's memory in MiB (burst always only; default the host's, 8192), at most the plan's workspace memory ceiling. */
955
+ burst_memory_mib?: number;
886
956
  };
887
957
  /** @description Names of customer secrets (contracts §17) to inject as environment variables NAME=value of this process only. The cell resolves them at session start through the API with the caller's tool token (permission-checked and audited by the API); values are never logged, persisted or returned by the cell. Any name the caller may not use (unknown, not permitted for this workspace/project/tool) refuses the whole start with 403 forbidden (details.reason secret_not_available, details.names); nothing is started. A name that is also a key of `env` is refused (422 validation_failed). Requires a workspace tool token (browser stream tickets cannot resolve secrets: 403, details.reason secret_refs_require_tool_token). Resolution failures: 401 (token revoked/expired), 409 stale_epoch, 429 rate_limited, 503 dependency_unavailable. A retried start with the same session_id re-resolves the names; if a value changed since the first start the host refuses the different request (409 conflict). */
888
958
  SecretRefs: string[];
@@ -980,6 +1050,88 @@ export interface components {
980
1050
  ended_at?: string | null;
981
1051
  /** @description Why the session ended without an exit code. For failed_to_start the command never ran and this names the cause, e.g. `working directory "/home/user/app" is not a directory` or `executable "foo" not found in PATH`. */
982
1052
  error?: string;
1053
+ memory_grow?: components["schemas"]["MemoryGrow"];
1054
+ burst?: components["schemas"]["BurstSummary"];
1055
+ };
1056
+ /** @description Burst execution (contracts §33.5): present on every session started with `burst: always`. The size, method and counts are set once the burst ended (the host's result); while it runs only host, applied and the requested size are known. */
1057
+ BurstSummary: {
1058
+ /**
1059
+ * @description Where the burst VM ran; `local` = the workspace's host (`remote` is reserved).
1060
+ * @enum {string}
1061
+ */
1062
+ host: "local" | "remote";
1063
+ /** @description The burst VM's vCPUs (the requested count while it runs, absent for the host default). */
1064
+ vcpus?: number;
1065
+ /** @description The burst VM's memory in MiB (the requested size while it runs, absent for the host default). */
1066
+ memory_mib?: number;
1067
+ /**
1068
+ * @description How the burst VM got the workspace copy.
1069
+ * @enum {string}
1070
+ */
1071
+ method?: "layer" | "clone";
1072
+ /** @description The command's file changes were applied to the workspace. */
1073
+ applied: boolean;
1074
+ /** Format: int64 */
1075
+ written_files?: number;
1076
+ /** Format: int64 */
1077
+ written_dirs?: number;
1078
+ /** Format: int64 */
1079
+ removed?: number;
1080
+ /** Format: int64 */
1081
+ written_bytes?: number;
1082
+ /** @description Processes the command left behind in the burst VM (killed; they do not survive a burst). */
1083
+ leftover_killed?: number;
1084
+ /**
1085
+ * Format: int64
1086
+ * @description Wall time of the burst minus the command's own run time.
1087
+ */
1088
+ overhead_ms?: number;
1089
+ /** @description The burst VM's own paths whose changes are never carried back (the host's denylist). */
1090
+ excluded_paths?: string[];
1091
+ /** @description The host's phase timings (ms) and counters. */
1092
+ timings?: {
1093
+ [key: string]: number;
1094
+ };
1095
+ /** @description The recorded outcome of an earlier attempt with the same session_id. */
1096
+ replayed?: boolean;
1097
+ /** @description Set when the burst failed after the start answered: burst_unavailable (the workspace is unchanged) or burst_apply_failed (details.applied_entries, details.pending_entries). */
1098
+ error?: components["schemas"]["BurstError"];
1099
+ };
1100
+ BurstError: {
1101
+ code: components["schemas"]["ErrorCode"];
1102
+ message: string;
1103
+ retryable: boolean;
1104
+ details?: {
1105
+ [key: string]: unknown;
1106
+ };
1107
+ };
1108
+ /** @description Elastic memory (contracts §32.3, §32.6): the host grew the workspace's memory before this exec started (the exec waited for it). Present only on the session returned by the start that grew the VM; absent when no grow ran (fixed workspaces, or already at the exec size). */
1109
+ MemoryGrow: {
1110
+ /**
1111
+ * @description delivered: the whole grow; partial: less than wanted; missed: nothing could be plugged; failed: the plug did not converge or errored.
1112
+ * @enum {string}
1113
+ */
1114
+ outcome: "delivered" | "partial" | "missed" | "failed";
1115
+ /**
1116
+ * Format: int64
1117
+ * @description Guest memory before the grow (MiB).
1118
+ */
1119
+ from_mib: number;
1120
+ /**
1121
+ * Format: int64
1122
+ * @description Guest memory the grow aimed for (MiB).
1123
+ */
1124
+ want_mib: number;
1125
+ /**
1126
+ * Format: int64
1127
+ * @description Guest memory after the grow (MiB).
1128
+ */
1129
+ got_mib: number;
1130
+ /**
1131
+ * Format: int64
1132
+ * @description Trigger to plugged and guest MemTotal settled (ms, rounded).
1133
+ */
1134
+ deliver_ms: number;
983
1135
  };
984
1136
  OutputEvent: {
985
1137
  /** @enum {string} */
@@ -999,7 +1151,10 @@ export interface components {
999
1151
  /** @enum {string} */
1000
1152
  type: "signal" | "cancel";
1001
1153
  signal?: components["schemas"]["SignalValue"];
1002
- /** Format: int64 */
1154
+ /**
1155
+ * Format: int64
1156
+ * @description For cancel, as CancelRequest.grace_ms; out of range is an `error` event (validation_failed).
1157
+ */
1003
1158
  grace_ms?: number;
1004
1159
  };
1005
1160
  /** @description Signal number (1-64) or name (e.g. "SIGTERM", "TERM"). */
@@ -1026,7 +1181,10 @@ export interface components {
1026
1181
  only_leader?: boolean;
1027
1182
  };
1028
1183
  CancelRequest: {
1029
- /** Format: int64 */
1184
+ /**
1185
+ * Format: int64
1186
+ * @description Time between the SIGTERM and the SIGKILL; 0 or omitted is 5000.
1187
+ */
1030
1188
  grace_ms?: number;
1031
1189
  };
1032
1190
  PtyOpenRequest: {
@@ -1545,12 +1703,16 @@ export interface operations {
1545
1703
  };
1546
1704
  };
1547
1705
  responses: {
1548
- /** @description The session already existed; nothing was run again (processful), or the recorded result of an existing execution (file-first). */
1706
+ /** @description An existing session/result, or followed output when a processful start accepts NDJSON/SSE. A failed-to-start session stays JSON. */
1549
1707
  200: {
1550
1708
  headers: {
1709
+ /** @description The streamed session id; replay/reconnect use the same session and byte offsets. */
1710
+ "X-Exec-Session-Id"?: string;
1551
1711
  [name: string]: unknown;
1552
1712
  };
1553
1713
  content: {
1714
+ "application/x-ndjson": string;
1715
+ "text/event-stream": string;
1554
1716
  "application/json": components["schemas"]["ExecSession"] | components["schemas"]["ExecutionResult"];
1555
1717
  };
1556
1718
  };
package/dist/http.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RetryRecord } from './progress.js';
2
- export declare const SDK_VERSION = "0.12.0";
2
+ export declare const SDK_VERSION = "0.13.1";
3
3
  export interface RequestOptions {
4
4
  query?: Record<string, string | number | boolean | undefined | null>;
5
5
  json?: unknown;
@@ -10,9 +10,19 @@ export interface RequestOptions {
10
10
  idempotencyKey?: string;
11
11
  /** The request has no effect a retry could duplicate (a read-only POST such as files/search): retried like a GET. */
12
12
  idempotent?: boolean;
13
+ /**
14
+ * false: the cell gateway's 429 `quota_exceeded` surfaces at once instead of being retried (the wake hint, which
15
+ * never waits). Default true.
16
+ */
17
+ quotaRetry?: boolean;
13
18
  signal?: AbortSignal;
14
19
  /** Override the client's default request timeout (ms); 0 disables it (streams). */
15
20
  timeoutMs?: number;
21
+ /**
22
+ * Bounds each attempt until the response headers arrive (ms), leaving the body unbounded: a stream that answers a
23
+ * start (exec.run's combined start and output). Usually with `timeoutMs: 0`.
24
+ */
25
+ headersTimeoutMs?: number;
16
26
  /** Called before each retry of a transient failure (lifecycle timing records these). */
17
27
  onRetry?: (retry: Omit<RetryRecord, 'atMs'>) => void;
18
28
  }
@@ -28,13 +38,45 @@ export interface HttpOptions {
28
38
  onSuccess?: (() => void) | undefined;
29
39
  }
30
40
  export declare const defaultSleep: (ms: number) => Promise<void>;
41
+ /**
42
+ * How long an idle pooled connection stays reusable (ms): 5 minutes, instead of undici's 4 s default, so the request
43
+ * after an agent's usual pause between tool calls (15 s to a few minutes) skips a new TCP and TLS handshake. Far below
44
+ * the 3600 s idle timeout of the load balancer in front of the API and the cell endpoints, so the server side never
45
+ * closes a connection for idleness while the client still treats it as reusable; below the AWS NAT gateway's 350 s idle
46
+ * timeout; and the same limit Chrome keeps for used idle sockets. Also caps a server's `Keep-Alive: timeout` hint.
47
+ */
48
+ export declare const KEEP_ALIVE_TIMEOUT_MS = 300000;
49
+ /**
50
+ * TCP keepalive probes start after a pooled socket has been idle this long (ms), so NATs and firewalls with shorter
51
+ * idle timeouts than the pool's (Azure SNAT: 4 minutes) keep the connection instead of silently dropping it. Equal to
52
+ * undici's own default, set explicitly so it is visible and tested.
53
+ */
54
+ export declare const TCP_KEEPALIVE_INITIAL_DELAY_MS = 60000;
55
+ /** The private pool's undici Agent options (exported for tests). */
56
+ export declare const POOL_OPTIONS: {
57
+ readonly allowH2: false;
58
+ readonly pipelining: 1;
59
+ readonly keepAliveTimeout: 300000;
60
+ readonly keepAliveMaxTimeout: 300000;
61
+ readonly connect: {
62
+ readonly keepAlive: true;
63
+ readonly keepAliveInitialDelay: 60000;
64
+ };
65
+ };
31
66
  /**
32
67
  * Reuses TLS connections. Node 26's bundled undici 8.9 can stall reused connections: use pinned undici with a
33
- * private HTTP/1.1-only dispatcher. Other runtimes retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close
34
- * for diagnosis; =1 retains the historical opt-in to native pooling. An explicitly supplied fetch is untouched.
68
+ * private HTTP/1.1-only dispatcher whose idle connections stay reusable for KEEP_ALIVE_TIMEOUT_MS. Other runtimes
69
+ * retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close for diagnosis; =1 retains the historical opt-in to
70
+ * native pooling. An explicitly supplied fetch is untouched. Idle pooled sockets do not keep the process alive.
35
71
  */
36
72
  export declare function defaultFetch(env?: Record<string, string | undefined> | undefined, undiciVersion?: string | undefined): typeof fetch;
37
73
  export declare function buildUrl(base: string, path: string, query?: RequestOptions['query']): string;
74
+ /**
75
+ * True when fetch failed because its connection closed or reset before a response arrived: what a request sees when
76
+ * it went out on a pooled connection the other side closed while it sat idle. Connect failures (ECONNREFUSED,
77
+ * ENOTFOUND, UND_ERR_CONNECT_TIMEOUT), timeouts and aborts are not this.
78
+ */
79
+ export declare function isStaleConnectionError(err: unknown): boolean;
38
80
  /** `X-Tree-Revision` (file-first workspaces): a non-negative integer, else null. */
39
81
  export declare function treeRevisionOf(headers: Headers): number | null;
40
82
  /** Turns a non-2xx response into a ShardfluxApiError (or a protocol error for undocumented bodies). */
package/dist/http.js CHANGED
@@ -4,18 +4,44 @@
4
4
  * retries only where a retry cannot duplicate an effect (safe methods,
5
5
  * requests carrying an Idempotency-Key, and read-only POSTs marked
6
6
  * `idempotent`). A retryable 429/502/503/504 (e.g. 503 `host_capacity`, 503
7
- * `wake_failed`) waits `Retry-After` (at most 5 s) before the retry.
7
+ * `wake_failed`) waits `Retry-After` (at most 5 s) before the retry. Such a request whose connection closed before
8
+ * any response (a pooled connection gone stale while idle) is sent again at once, once, outside `maxRetries`. The cell
9
+ * gateway's 429 `quota_exceeded` (every working slot of the plan is taken) is retried for any request (0.14.0+): it is
10
+ * refused at admission, so nothing ran.
8
11
  */
9
- import { ShardfluxApiError, ShardfluxProtocolError, apiError, isErrorBody } from "./errors.js";
12
+ import { ShardfluxApiError, ShardfluxProtocolError, apiError, isErrorBody, isWorkingQuotaRefusal } from "./errors.js";
10
13
  import { describeFailure } from "./progress.js";
11
- export const SDK_VERSION = '0.12.0';
14
+ export const SDK_VERSION = '0.13.1';
12
15
  export const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ /**
17
+ * How long an idle pooled connection stays reusable (ms): 5 minutes, instead of undici's 4 s default, so the request
18
+ * after an agent's usual pause between tool calls (15 s to a few minutes) skips a new TCP and TLS handshake. Far below
19
+ * the 3600 s idle timeout of the load balancer in front of the API and the cell endpoints, so the server side never
20
+ * closes a connection for idleness while the client still treats it as reusable; below the AWS NAT gateway's 350 s idle
21
+ * timeout; and the same limit Chrome keeps for used idle sockets. Also caps a server's `Keep-Alive: timeout` hint.
22
+ */
23
+ export const KEEP_ALIVE_TIMEOUT_MS = 300_000;
24
+ /**
25
+ * TCP keepalive probes start after a pooled socket has been idle this long (ms), so NATs and firewalls with shorter
26
+ * idle timeouts than the pool's (Azure SNAT: 4 minutes) keep the connection instead of silently dropping it. Equal to
27
+ * undici's own default, set explicitly so it is visible and tested.
28
+ */
29
+ export const TCP_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
30
+ /** The private pool's undici Agent options (exported for tests). */
31
+ export const POOL_OPTIONS = {
32
+ allowH2: false,
33
+ pipelining: 1,
34
+ keepAliveTimeout: KEEP_ALIVE_TIMEOUT_MS,
35
+ keepAliveMaxTimeout: KEEP_ALIVE_TIMEOUT_MS,
36
+ connect: { keepAlive: true, keepAliveInitialDelay: TCP_KEEPALIVE_INITIAL_DELAY_MS },
37
+ };
13
38
  /** One private HTTP/1.1 pool on Node 26+, shared by SDK clients. No global dispatcher changes. */
14
39
  let nodeFetch;
15
40
  /**
16
41
  * Reuses TLS connections. Node 26's bundled undici 8.9 can stall reused connections: use pinned undici with a
17
- * private HTTP/1.1-only dispatcher. Other runtimes retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close
18
- * for diagnosis; =1 retains the historical opt-in to native pooling. An explicitly supplied fetch is untouched.
42
+ * private HTTP/1.1-only dispatcher whose idle connections stay reusable for KEEP_ALIVE_TIMEOUT_MS. Other runtimes
43
+ * retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close for diagnosis; =1 retains the historical opt-in to
44
+ * native pooling. An explicitly supplied fetch is untouched. Idle pooled sockets do not keep the process alive.
19
45
  */
20
46
  export function defaultFetch(env = globalThis.process?.env, undiciVersion = globalThis.process?.versions?.undici) {
21
47
  // Why a fresh connection on Node 26 (its bundled HTTP client, measured): docs/progress/startup-latency.md.
@@ -30,7 +56,7 @@ export function defaultFetch(env = globalThis.process?.env, undiciVersion = glob
30
56
  return base;
31
57
  return async (input, init) => {
32
58
  nodeFetch ??= import('undici').then(({ Agent, fetch: pooledFetch }) => {
33
- const dispatcher = new Agent({ allowH2: false, pipelining: 1 });
59
+ const dispatcher = new Agent({ ...POOL_OPTIONS, connect: { ...POOL_OPTIONS.connect } });
34
60
  return async (input, init) => {
35
61
  const response = await pooledFetch(input, { ...init, dispatcher });
36
62
  // Web-standard runtime shape; undici and DOM iterator declarations differ.
@@ -49,6 +75,25 @@ export function buildUrl(base, path, query) {
49
75
  }
50
76
  return u.toString();
51
77
  }
78
+ /** Codes (on fetch's `cause` chain) of a connection that closed or reset before the response arrived. */
79
+ const STALE_CONNECTION_CODES = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CLOSED']);
80
+ /**
81
+ * True when fetch failed because its connection closed or reset before a response arrived: what a request sees when
82
+ * it went out on a pooled connection the other side closed while it sat idle. Connect failures (ECONNREFUSED,
83
+ * ENOTFOUND, UND_ERR_CONNECT_TIMEOUT), timeouts and aborts are not this.
84
+ */
85
+ export function isStaleConnectionError(err) {
86
+ if (!(err instanceof TypeError))
87
+ return false;
88
+ const seen = new Set();
89
+ for (let c = err.cause; typeof c === 'object' && c !== null && !seen.has(c); c = c.cause) {
90
+ seen.add(c);
91
+ const code = c.code;
92
+ if (typeof code === 'string' && STALE_CONNECTION_CODES.has(code))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
52
97
  function retryAfterSeconds(res) {
53
98
  const v = res.headers.get('retry-after');
54
99
  if (v === null)
@@ -89,6 +134,9 @@ export class HttpClient {
89
134
  const safe = method === 'GET' || method === 'HEAD';
90
135
  const retriable = safe || init.idempotencyKey !== undefined || init.idempotent === true;
91
136
  const sleep = this.opts.sleep ?? defaultSleep;
137
+ // Retries counted against maxRetries (they also set the backoff); `attempt` counts every send.
138
+ let counted = 0;
139
+ let staleReplayed = false;
92
140
  for (let attempt = 0;; attempt += 1) {
93
141
  const headers = {
94
142
  accept: init.accept ?? 'application/json',
@@ -108,7 +156,13 @@ export class HttpClient {
108
156
  }
109
157
  const timeoutMs = init.timeoutMs ?? this.opts.timeoutMs;
110
158
  const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
111
- const signal = timeout && init.signal ? AbortSignal.any([timeout, init.signal]) : (timeout ?? init.signal);
159
+ const headersMs = init.headersTimeoutMs ?? 0;
160
+ const headersAbort = headersMs > 0 ? new AbortController() : undefined;
161
+ const headersTimer = headersAbort
162
+ ? setTimeout(() => headersAbort.abort(new DOMException('The response headers timed out.', 'TimeoutError')), headersMs)
163
+ : undefined;
164
+ const signals = [timeout, init.signal, headersAbort?.signal].filter((s) => s !== undefined);
165
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
112
166
  let res;
113
167
  try {
114
168
  res = await this.opts.fetch(url, { method, headers, ...(body === undefined ? {} : { body: body }), ...(signal ? { signal } : {}) });
@@ -116,13 +170,27 @@ export class HttpClient {
116
170
  catch (err) {
117
171
  if (init.signal?.aborted)
118
172
  throw err;
119
- if (!retriable || attempt >= this.opts.maxRetries)
173
+ if (!retriable)
120
174
  throw err;
121
- const delayMs = Math.min(2_000, 200 * 2 ** attempt);
122
- init.onRetry?.({ request: `${method} ${path}`, attempt: attempt + 1, cause: describeFailure(err, timeoutMs), delayMs });
175
+ if (!staleReplayed && !signal?.aborted && isStaleConnectionError(err)) {
176
+ // The connection closed before any response, typically a pooled one the server or a NAT closed while idle:
177
+ // send again at once on a fresh connection, once per request and outside maxRetries (as Go's net/http
178
+ // replays a request that failed on a reused connection when it is idempotent or carries an Idempotency-Key).
179
+ staleReplayed = true;
180
+ init.onRetry?.({ request: `${method} ${path}`, attempt: attempt + 1, cause: describeFailure(err), delayMs: 0 });
181
+ continue;
182
+ }
183
+ if (counted >= this.opts.maxRetries)
184
+ throw err;
185
+ const delayMs = Math.min(2_000, 200 * 2 ** counted);
186
+ counted += 1;
187
+ init.onRetry?.({ request: `${method} ${path}`, attempt: attempt + 1, cause: describeFailure(err, headersAbort?.signal.aborted ? headersMs : timeoutMs), delayMs });
123
188
  await sleep(delayMs);
124
189
  continue;
125
190
  }
191
+ finally {
192
+ clearTimeout(headersTimer);
193
+ }
126
194
  if (res.ok) {
127
195
  if (this.opts.onSuccess) {
128
196
  try {
@@ -137,10 +205,15 @@ export class HttpClient {
137
205
  const error = await errorFrom(res, this.opts.source);
138
206
  const retryableStatus = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
139
207
  const retryableError = error instanceof ShardfluxApiError ? error.retryable && retryableStatus : retryableStatus;
140
- if (!retriable || !retryableError || attempt >= this.opts.maxRetries)
208
+ // The working-at-once quota (cell 429 quota_exceeded): the gateway refuses the call at admission, before the
209
+ // workspace is touched, so nothing ran and even a non-idempotent request (exec start, stdin, PTY create,
210
+ // keepalive) is safe to send again. Same Retry-After wait and retry budget as every other retry.
211
+ const admissionRefusal = init.quotaRetry !== false && isWorkingQuotaRefusal(error);
212
+ if ((!retriable && !admissionRefusal) || !retryableError || counted >= this.opts.maxRetries)
141
213
  throw error;
142
214
  const hinted = error instanceof ShardfluxApiError ? error.retryAfterSeconds : undefined;
143
- const delayMs = Math.min(5_000, hinted !== undefined ? hinted * 1000 : 200 * 2 ** attempt);
215
+ const delayMs = Math.min(5_000, hinted !== undefined ? hinted * 1000 : 200 * 2 ** counted);
216
+ counted += 1;
144
217
  init.onRetry?.({ request: `${method} ${path}`, attempt: attempt + 1, cause: describeFailure(error), delayMs });
145
218
  await sleep(delayMs);
146
219
  }
package/dist/index.d.ts CHANGED
@@ -15,10 +15,10 @@
15
15
  export type { components, operations, paths } from './generated/app-api.js';
16
16
  export type { components as CellComponents, paths as CellPaths } from './generated/cell-api.js';
17
17
  export { BillingApi, Shardflux, WorkspacesApi, fetchBillingCatalog, pickByKey } from './client.js';
18
- export type { AgentSession, BillingCatalog, BillingSubscription, Caps, CheckoutSession, DiskLayout, Entitlements, FindByKeyOptions, ForkTarget, Invoice, InvoicePage, LifetimeFilter, ListParams, Me, OpenParams, OpenResponse, Operation, Page, PortalSession, PurposeFilter, ResetWorkspaceBody, ResumeAnswer, ResumeRequestOptions, ResumeResponse, ShardfluxOptions, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResponse, SuspendWhenIdleResult, UpdatePolicy, WaitOptions, WorkspaceInputs, WorkspaceLifetime, WorkspaceOrigin, WorkspacePurpose, WorkspaceView, } from './client.js';
19
- export type { FinishedOperation, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
20
- export { durabilityOf, formatTiming, isDurable, lostSuspendOf } from './progress.js';
21
- export type { Durability, LostSuspend, LifecycleAction, LifecyclePhase, LifecycleTiming, ProgressEvent, ProgressListener, RetryRecord, ServerTiming, TimingOutcome, TimingPhase, } from './progress.js';
18
+ export type { AgentSession, AllocationMode, BillingCatalog, BillingSubscription, Caps, CheckoutSession, DiskLayout, Entitlements, FindByKeyOptions, ForkTarget, Invoice, InvoicePage, LifetimeFilter, ListParams, Me, OpenParams, OpenResponse, Operation, Page, PortalSession, PurposeFilter, ResetWorkspaceBody, ResumeAnswer, ResumeRequestOptions, ResumeResponse, ShardfluxOptions, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResponse, SuspendWhenIdleResult, WaitOptions, WorkspaceInputs, WorkspaceLifetime, WorkspaceMemory, WorkspaceOrigin, WorkspacePurpose, WorkspaceView, } from './client.js';
19
+ export type { FinishedOperation, ForkOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedForkOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
20
+ export { durabilityOf, formatTiming, hostLostOf, isDurable, lostSuspendOf } from './progress.js';
21
+ export type { ColdBootReason, Durability, HostLost, LostSuspend, LifecycleAction, LifecyclePhase, LifecycleTiming, ProgressEvent, ProgressListener, RetryRecord, ServerTiming, TimingOutcome, TimingPhase, } from './progress.js';
22
22
  export { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH } from './feedback.js';
23
23
  export type { AccountFeedbackParams, FeedbackCategory, FeedbackContext, FeedbackReceipt, SendFeedbackParams } from './feedback.js';
24
24
  export { UsageApi } from './usage.js';
@@ -28,7 +28,7 @@ export { TemplateFileError, packDirectory, parseTemplateText, readTemplateFile }
28
28
  export type { PackedFile, YamlParser } from './template-file.js';
29
29
  export { tarEnd, tarHeader, tarPadding } from './tar.js';
30
30
  export type { TarEntry, TarEntryType } from './tar.js';
31
- export type { BuildFromFileEvent, BuildFromFileOptions, BuildFromFileResult, BuildFromRecipeOptions, BuilderAvailability, CreateDraftBody, CreateDraftParams, CreateTemplateBuildParams, CreateTestInstanceBody, CreateVersionTestInstanceBody, CreateVersionTestInstanceParams, DraftOpened, LocalUpload, DraftState, OpenTestInstanceParams, OrgTemplateStorage, PublishDraftBody, PublishDraftParams, PutUploadOptions, SaveAsTemplateBody, SaveAsTemplateParams, SaveAsTemplateResponse, TemplateBuild, TemplateBuildLogUrl, TemplateBuildRecipeV2, TemplateBuildRegistrationState, TemplateBuildState, TemplateCategory, TemplateDefaults, TemplateDefaultsInput, TemplateDetail, TemplateDiffChange, TemplateDiffEntry, TemplateDiffPage, TemplateDiffParams, TemplateDraft, TemplateDraftSummary, TemplateEgressDefault, TemplateFileEntry, TemplateFilePage, TemplateFilesParams, TemplateFilesSummary, TemplateInput, TemplateLanguage, TemplateLanguages, TemplateOwner, TemplatePackage, TemplatePackageEcosystem, TemplatePackagePage, TemplateRecipe, TemplateRecipeV2, TemplateRecipeV2File, TemplateService, TemplateSettings, TemplateSettingsInput, TemplateSource, TemplateStartCommand, TemplateStorage, TemplateStorageWarning, TemplateSummary, TemplateUpload, TemplateUploadRequest, TemplateUploadResponse, TemplateUploadResult, TemplateVersion, TemplateVersionRecipe, TemplateVersionState, UploadData, WaitForBuildOptions, WorkspaceStartup, } from './templates.js';
31
+ export type { BuildFromFileEvent, BuildFromFileOptions, BuildFromFileResult, BuildFromRecipeOptions, BuilderAvailability, CreateDraftBody, CreateDraftParams, CreateTemplateBuildParams, CreateTestInstanceBody, CreateVersionTestInstanceBody, CreateVersionTestInstanceParams, DraftOpened, LocalUpload, DraftState, OpenTestInstanceParams, OrgTemplateStorage, PublishDraftBody, PublishDraftParams, PutUploadOptions, SaveAsTemplateBody, SaveAsTemplateParams, SaveAsTemplateResponse, TemplateBuild, TemplateBuildLogUrl, TemplateBuildRecipeV2, TemplateBuildRegistrationState, TemplateBuildState, TemplateCategory, TemplateDefaults, TemplateDefaultsInput, TemplateDetail, TemplateDiffChange, TemplateDiffEntry, TemplateDiffPage, TemplateDiffParams, TemplateDraft, TemplateDraftSummary, TemplateEgressDefault, TemplateFileEntry, TemplateFilePage, TemplateFilesParams, TemplateFilesSummary, TemplateInput, TemplateLanguage, TemplateLanguages, TemplateOwner, TemplatePackage, TemplatePackageEcosystem, TemplatePackagePage, TemplateRecipe, TemplateRecipeV2, TemplateRecipeV2File, TemplateService, TemplateSettings, TemplateSettingsInput, TemplateSource, TemplateStartCommand, TemplateStorage, TemplateStorageWarning, TemplateSummary, TemplateUpload, TemplateUploadRequest, TemplateUploadResponse, TemplateUploadResult, TemplateVersion, TemplateVersionImmutable, TemplateVersionRecipe, TemplateVersionState, UploadData, WaitForBuildOptions, WorkspaceStartup, } from './templates.js';
32
32
  export { SecretsApi, WorkspaceSecrets } from './secrets.js';
33
33
  export type { BoundSecretStatus, CreateOrganizationSecretParams, CreateSecretParams, Secret, SecretAccessEvent, SecretPermissions, SecretScope, SecretVersion, UpdateSecretParams, WorkspaceSecretBindings, } from './secrets.js';
34
34
  export { EgressPolicyApi } from './egress.js';
@@ -42,7 +42,7 @@ export type { HintOptions, HintResult, WakeOptions } from './workspace.js';
42
42
  export { CellClient, DEFAULT_TRANSITION_TIMEOUT_MS, cellPath, ndjson } from './cell.js';
43
43
  export { EXECUTION_ID, ExecutionResult, newExecutionId } from './executions.js';
44
44
  export type { ExecutionChange, ExecutionError, ExecutionGetOptions, ExecutionResultBody, ExecutionRunOptions, ExecutionState } from './executions.js';
45
- export type { TreeRevisionOptions, BrowserContent, BrowserContentRequest, BrowserScreenshotRequest, CellClientOptions, ExecSession, ExecStartRequest, FileEdit, FileInfo, FileList, FilePatchEdit, FilePatchParams, FilePatchRequest, FilePatchResult, FileReadResult, FileRevision, FileSearchMatch, FileSearchOptions, FileSearchRequest, FileSearchResponse, FileSearchResult, FileWriteResult, GitResult, GitStatus, OutputEvent, ProcessList, PtyOpenRequest, PtySession, Residency, RunOptions, RunResult, ServedFrom, Signal, WakeHintResult, WorkspaceChange, WorkspaceChangeKind, WorkspaceChangesPage, WorkspaceChangesParams, WorkspaceChangesSummary, } from './cell.js';
45
+ export type { TreeRevisionOptions, BrowserContent, BrowserContentRequest, BrowserScreenshotRequest, CellClientOptions, ExecSession, ExecStartRequest, MemoryGrow, BurstSummary, BurstError, FileEdit, FileInfo, FileList, FilePatchEdit, FilePatchParams, FilePatchRequest, FilePatchResult, FileReadResult, FileRevision, FileSearchMatch, FileSearchOptions, FileSearchRequest, FileSearchResponse, FileSearchResult, FileWriteResult, GitResult, GitStatus, OutputEvent, ProcessList, PtyOpenRequest, PtySession, Residency, RunOptions, RunResult, ServedFrom, Signal, WakeHintResult, WorkspaceChange, WorkspaceChangeKind, WorkspaceChangesPage, WorkspaceChangesParams, WorkspaceChangesSummary, } from './cell.js';
46
46
  export { ToolTokenManager } from './tokens.js';
47
47
  export type { ToolName, ToolToken, ToolTokenOptions } from './tokens.js';
48
48
  export { ToolArgumentError, executeToolCall, toAnthropicTools, toOpenAITools, validateArgs, workspaceTools } from './tools.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { BillingApi, Shardflux, WorkspacesApi, fetchBillingCatalog, pickByKey } from "./client.js";
2
- export { durabilityOf, formatTiming, isDurable, lostSuspendOf } from "./progress.js";
2
+ export { durabilityOf, formatTiming, hostLostOf, isDurable, lostSuspendOf } from "./progress.js";
3
3
  export { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH } from "./feedback.js";
4
4
  export { UsageApi } from "./usage.js";
5
5
  export { TemplateBuildTimeoutError, TemplateBuildsApi, TemplateDraftApi, TemplatePackagesApi, TemplateUploadError, TemplateUploadsApi, TemplateVersionTestInstancesApi, TemplateVersionsApi, TemplatesApi, buildSettled, saveAsTemplateBody, } from "./templates.js";
@@ -61,6 +61,11 @@ export interface ResumeOptions extends LifecycleOptions {
61
61
  export type WaitedResumeOptions = ResumeOptions & {
62
62
  wait: true | WaitOptions;
63
63
  };
64
+ /** A waited fork (0.13.0+) returns the target view and token together when the API supports it. */
65
+ export type ForkOptions = ResumeOptions;
66
+ export type WaitedForkOptions = ForkOptions & {
67
+ wait: true | WaitOptions;
68
+ };
64
69
  /** Internal: the trace a wait continues instead of starting its own (open(), wake() and waited lifecycle calls). */
65
70
  export declare const TRACE: unique symbol;
66
71
  /** Internal: work a workspace handle does after the operation finished, inside the same trace (refreshing its view). */
@@ -88,6 +93,7 @@ export declare function waitOptionsOf(opts: LifecycleOptions): WaitOptions | nul
88
93
  * Starts a lifecycle operation with `start` and, when `opts.wait` asks for it, waits for it to finish. The trace covers
89
94
  * the request, every observed state, and `AFTER_WAIT`; it ends with `done` either way.
90
95
  */
91
- export declare function runLifecycle(ctx: ClientContext, kind: LifecycleAction, workspaceId: string, start: (init: Pick<RequestOptions, 'onRetry'>) => Promise<Operation>, opts: InternalLifecycleOptions, capture?: {
96
+ export declare function runLifecycle(ctx: ClientContext, kind: LifecycleAction, workspaceId: string, start: (init: Pick<RequestOptions, 'onRetry'>, trace: Trace) => Promise<Operation>, opts: InternalLifecycleOptions, capture?: {
92
97
  settle?: boolean;
98
+ requestReason?: string | null;
93
99
  }): Promise<Operation>;
package/dist/lifecycle.js CHANGED
@@ -28,8 +28,8 @@ export async function runLifecycle(ctx, kind, workspaceId, start, opts, capture
28
28
  const pending = capture.settle ? ctx.captures.settle(workspaceId) : undefined;
29
29
  if (pending)
30
30
  await trace.span('capture_flush', () => pending);
31
- trace.phase('request');
32
- const operation = await start({ onRetry: trace.onRetry });
31
+ trace.phase('request', capture.requestReason ?? null);
32
+ const operation = await start({ onRetry: trace.onRetry }, trace);
33
33
  trace.observe(operation);
34
34
  if (!waitOpts)
35
35
  return operation;