ai-sdk-harness-sessions 0.2.0 → 0.2.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.
- package/CHANGELOG.md +16 -0
- package/README.md +108 -15
- package/dist/definitions/manager.d.ts +20 -1
- package/dist/definitions/store.d.ts +32 -1
- package/dist/errors/session-owned-elsewhere-error.d.ts +19 -0
- package/dist/errors/session-owned-elsewhere-error.js +22 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/manager/session-leases.d.ts +38 -0
- package/dist/manager/session-leases.js +70 -0
- package/dist/manager/session-manager.d.ts +4 -2
- package/dist/manager/session-manager.js +204 -50
- package/dist/sandboxes/shared-sandbox.d.ts +2 -2
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# ai-sdk-harness-sessions
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d1448b3: Closing or deleting an `interrupted` session now discards its sandbox, as it does for a `suspended` one. A session the process left live when it stopped without suspending it kept its sandbox forever: with `sandboxPerSession()`, a session created again under the same id then failed, its sandbox's name already taken.
|
|
8
|
+
- 116b98c: A resume that fails no longer closes the session's sandbox lease: it hands it back with `suspend()`, as a suspension does, so the sandbox and the files the next try resumes from are kept. With `sandboxPerSession()`, a failed resume destroyed the session's sandbox while the session stayed `suspended`, waiting to resume from it.
|
|
9
|
+
- a1197a2: Several managers can share one store: the instances of an application behind a load balancer, scaled out, or a new version deployed beside the old one. A store that implements three more methods — `claimSession`, `renewLease` and `releaseSession`, the lease of each manager kept on the database's clock — lets each manager run the sessions it holds:
|
|
10
|
+
|
|
11
|
+
- a starting manager no longer marks `interrupted` the live sessions of the others, only those of a manager whose lease expired;
|
|
12
|
+
- a request for a session another manager runs fails with `SessionOwnedElsewhereError` (a `SessionConflictError`), which names that manager (`owner`, the new `managerId` option) so that the application can route the request to it;
|
|
13
|
+
- a suspended session is resumed by whichever manager receives its next message;
|
|
14
|
+
- once the lease of a manager that crashed has expired, the others mark its live sessions `interrupted`.
|
|
15
|
+
|
|
16
|
+
`leaseTtlMs` sets the lease (30 seconds by default). A store without these methods works as before, with one manager.
|
|
17
|
+
- 32e5121: The message sent after a turn cut short is answered. Claude Code goes on with a turn for a moment after it is told to stop, and the harness session handed what it still said to the next turn: that turn failed with "Received text-delta for missing text part", or ended at once without its answer. A turn cut short by `interrupt()`, `turnTimeoutMs` or the caller's `abortSignal` now starts its harness session again, in the same sandbox and the same conversation, before the next turn.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ import { createSessionManager, sharedSandbox } from 'ai-sdk-harness-sessions';
|
|
|
22
22
|
const sessions = createSessionManager({
|
|
23
23
|
// The agent of a session, from what you keep with it.
|
|
24
24
|
agent: ({ metadata }) => agents[metadata.agent],
|
|
25
|
-
// One sandbox for every session, each with a port of its own for its bridge.
|
|
25
|
+
// One sandbox for every session, each with a port of its own for its bridge. `openSandbox` is
|
|
26
|
+
// yours: it opens a sandbox of ai-sdk-sandbox-sbx, ai-sdk-sandbox-cloud-run… (see "Sandboxes").
|
|
26
27
|
sandboxes: sharedSandbox({
|
|
27
28
|
open: openSandbox,
|
|
28
29
|
ports: Array.from({ length: 4 }, (_, i) => 4001 + i),
|
|
@@ -46,6 +47,7 @@ return turn.toUIMessageStreamResponse();
|
|
|
46
47
|
| Every sandbox slot is taken | Suspends the session idle the longest to make room |
|
|
47
48
|
| Your server stops (`shutdown()`) | Cuts the turns under way short, and suspends every session: the next start resumes them on their next message |
|
|
48
49
|
| Your server crashes | Marks the sessions it held `interrupted` at the next start: their conversation can still be read |
|
|
50
|
+
| Your server runs on several instances | With a store that gives leases, each instance runs the sessions it holds, and takes over those of one that crashed |
|
|
49
51
|
| You want the conversation again | `get(id)` gives it as `useChat` shows it, `list()` every session, `subscribe()` every change, for a list to follow |
|
|
50
52
|
|
|
51
53
|
## Installation
|
|
@@ -134,6 +136,8 @@ export async function POST(request: Request) {
|
|
|
134
136
|
|
|
135
137
|
The turn is read to its end on your server as well: a user who closes the tab does not leave the session hanging, and the session is written either way. `abortSignal: request.signal` lets the stop button of `useChat` cut the turn short; leave it out for turns that go on without the client. `interrupt(id)` cuts a turn short from anywhere.
|
|
136
138
|
|
|
139
|
+
A turn cut short — by `interrupt(id)`, `turnTimeoutMs` or the caller's `abortSignal` — starts its harness session again, in the same sandbox, before the session takes another message. A runtime goes on with a turn for a moment after it is told to stop, and its harness session would hand what it still says to the next turn: that turn would end at once, its answer lost ([vercel/ai#23507](https://github.com/vercel/ai/issues/23507)). The conversation goes on where it stopped; starting again takes a few seconds. A harness session that cannot start again leaves the session `suspended`, resumed by the next message.
|
|
140
|
+
|
|
137
141
|
### Showing the conversations again
|
|
138
142
|
|
|
139
143
|
```ts
|
|
@@ -183,16 +187,16 @@ stateDiagram-v2
|
|
|
183
187
|
busy --> interrupted: the server crashed
|
|
184
188
|
```
|
|
185
189
|
|
|
186
|
-
| Status | What it means
|
|
187
|
-
| ---------------- |
|
|
188
|
-
| `preparing` | Its sandbox and harness session are being opened, or brought back from `suspended`. A message sent meanwhile waits
|
|
189
|
-
| `idle` | Ready for a message
|
|
190
|
-
| `busy` | A turn is under way. Another message is refused until it ends
|
|
191
|
-
| `awaiting-input` | The turn paused on a tool call that waits for you: an approval, or the result of a tool your application runs
|
|
192
|
-
| `suspended` | Stopped, its state kept in the store and its sandbox freed. The next message, or `continue()`, brings it back
|
|
193
|
-
| `closed` | Ended for good. Its conversation can still be read
|
|
194
|
-
| `failed` | A new session that could not start: a sandbox that did not open, say. The next message tries again
|
|
195
|
-
| `interrupted` | Cannot be brought back: the server stopped without suspending it. Its conversation can still be read
|
|
190
|
+
| Status | What it means |
|
|
191
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
192
|
+
| `preparing` | Its sandbox and harness session are being opened, or brought back from `suspended`. A message sent meanwhile waits |
|
|
193
|
+
| `idle` | Ready for a message |
|
|
194
|
+
| `busy` | A turn is under way. Another message is refused until it ends |
|
|
195
|
+
| `awaiting-input` | The turn paused on a tool call that waits for you: an approval, or the result of a tool your application runs |
|
|
196
|
+
| `suspended` | Stopped, its state kept in the store and its sandbox freed. The next message, or `continue()`, brings it back |
|
|
197
|
+
| `closed` | Ended for good. Its conversation can still be read |
|
|
198
|
+
| `failed` | A new session that could not start: a sandbox that did not open, say. The next message tries again |
|
|
199
|
+
| `interrupted` | Cannot be brought back: the server stopped without suspending it. Its conversation can still be read; `close()` or `delete()` frees its sandbox |
|
|
196
200
|
|
|
197
201
|
A session that fails to resume stays `suspended`, with the reason in its `error`: the next message tries again from the same state.
|
|
198
202
|
|
|
@@ -261,7 +265,7 @@ Where each session runs is up to `sandboxes`:
|
|
|
261
265
|
| `sandboxPerSession({ create, resume, maxSessions })` | A sandbox of its own, stopped when the session is suspended, destroyed when it is closed | Sessions that must not see each other: one per user or per repository |
|
|
262
266
|
| Your own `SessionSandboxes` | What `acquire(sessionId, { resume })` returns | A pool, a sandbox per user shared by their sessions… |
|
|
263
267
|
|
|
264
|
-
`sharedSandbox()` takes sandboxes that give views of themselves, `fork({ ports })`: those of `ai-sdk-sandbox-sbx` and `ai-sdk-sandbox-
|
|
268
|
+
`sharedSandbox()` takes sandboxes that give views of themselves, `fork({ ports })`: those of `ai-sdk-sandbox-sbx`, `ai-sdk-sandbox-cloud-run` and `ai-sdk-sandbox-runtime`. A suspended session hands its port back and keeps its files in the sandbox. With `stopWhenUnused`, the sandbox itself is stopped once no session holds it, and opened again with the next one: on Cloud Run, a stopped sandbox costs its snapshot's storage alone.
|
|
265
269
|
|
|
266
270
|
```ts
|
|
267
271
|
import { createSbxNetworkSandboxSession, resumeSbxNetworkSandboxSession } from 'ai-sdk-sandbox-sbx';
|
|
@@ -276,6 +280,48 @@ const sandboxes = sandboxPerSession({
|
|
|
276
280
|
});
|
|
277
281
|
```
|
|
278
282
|
|
|
283
|
+
### Opening the sandbox
|
|
284
|
+
|
|
285
|
+
The package imports no sandbox package: the `open()` of `sharedSandbox()`, and the `create()` and `resume()` of `sandboxPerSession()`, are functions you write with the sandbox package of your choice. Throughout this documentation, `openSandbox` is the function of [The manager](#the-manager): it finds the sandbox again when it exists, and creates it otherwise. To run the sessions somewhere else, change that function and nothing else:
|
|
286
|
+
|
|
287
|
+
| Sandbox package | Its functions | `sharedSandbox()` | `sandboxPerSession()` |
|
|
288
|
+
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- | --------------------- |
|
|
289
|
+
| [`ai-sdk-sandbox-sbx`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-sbx) | `createSbxNetworkSandboxSession()`, `resumeSbxNetworkSandboxSession()` | Yes | Yes |
|
|
290
|
+
| [`ai-sdk-sandbox-cloud-run`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-cloud-run) | `createCloudRunNetworkSandboxSession()`, `resumeCloudRunNetworkSandboxSession()` | Yes | Yes |
|
|
291
|
+
| [`ai-sdk-sandbox-runtime`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-runtime) | `createSrtNetworkSandboxSession()`, `resumeSrtNetworkSandboxSession()` | Yes | Yes |
|
|
292
|
+
| [`ai-sdk-sandbox-microsandbox`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-microsandbox) | `createMicrosandboxNetworkSandboxSession()`, `resumeMicrosandboxNetworkSandboxSession()` | No: it has no `fork()` | Yes |
|
|
293
|
+
|
|
294
|
+
The same `openSandbox` on Cloud Run, through the sandbox service you deployed:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import {
|
|
298
|
+
CloudRunSandboxNotFoundError,
|
|
299
|
+
createCloudRunNetworkSandboxSession,
|
|
300
|
+
resumeCloudRunNetworkSandboxSession,
|
|
301
|
+
} from 'ai-sdk-sandbox-cloud-run';
|
|
302
|
+
|
|
303
|
+
async function openSandbox() {
|
|
304
|
+
const settings = {
|
|
305
|
+
url: process.env.CLOUD_RUN_SANDBOX_URL!,
|
|
306
|
+
sandboxId: 'my-app',
|
|
307
|
+
ports: [4000],
|
|
308
|
+
// The bridges install their dependencies from npm.
|
|
309
|
+
allowedHosts: ['registry.npmjs.org'],
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
const sandbox = await resumeCloudRunNetworkSandboxSession(settings);
|
|
313
|
+
await sandbox.killAllProcesses();
|
|
314
|
+
return sandbox;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (!(error instanceof CloudRunSandboxNotFoundError)) throw error;
|
|
317
|
+
}
|
|
318
|
+
const template = await createHarnessSandboxTemplate({ harnesses: [harness] });
|
|
319
|
+
return createCloudRunNetworkSandboxSession({ ...settings, template });
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) picks one of the four with an environment variable, `EXAMPLE_SANDBOX`: see its `lib/sandbox.ts`.
|
|
324
|
+
|
|
279
325
|
### How many sessions run at once
|
|
280
326
|
|
|
281
327
|
Claude Code and Codex run behind a bridge: a process the harness starts in the sandbox for each session, listening on a port of the sandbox that your server reaches over a WebSocket (published on `127.0.0.1` by `sbx`, tunnelled by the Cloud Run service). The harness takes the first port of the sandbox session it is given, so two sessions handed the same sandbox would both listen on it. `sharedSandbox()` gives each session a view of the sandbox with a port of its own: the same microVM, the same installed tools, a working directory of its own, and a port of `ports` for its bridge.
|
|
@@ -323,7 +369,51 @@ const store: SessionStore<Metadata> = {
|
|
|
323
369
|
|
|
324
370
|
A resume state holds secrets: the token of the harness bridge, the placeholders of the credentials. Keep it apart from what you show, as you would a session token. The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) keeps its sessions in JSON files (`lib/session-store.ts`).
|
|
325
371
|
|
|
326
|
-
Keep one manager per store: at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
|
|
372
|
+
Keep one manager per store, unless the store gives leases (below): at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
|
|
373
|
+
|
|
374
|
+
### Several instances
|
|
375
|
+
|
|
376
|
+
An application that runs on several instances — behind a load balancer, scaled out, or a new version deployed beside the old one — shares one store between their managers. The store tells them apart with leases: three more methods, on the same database, and the managers share it.
|
|
377
|
+
|
|
378
|
+
- Each manager holds the sessions it runs, and renews its own lease three times within `leaseTtlMs` (30 seconds by default).
|
|
379
|
+
- A request for a session another manager runs fails with `SessionOwnedElsewhereError`, which names it (`owner`, its `managerId`): send the request there, or answer 409 and let the client try again. Reading a session, `get()` and `list()`, works from any instance.
|
|
380
|
+
- A suspended session is held by nobody: the next message resumes it on whichever instance receives it.
|
|
381
|
+
- A manager that crashed stops renewing its lease. Once it expired, the others mark its live sessions `interrupted`, within a lease or so, and so does any manager that starts.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
const store: SessionStore<Metadata> = {
|
|
385
|
+
// …the five methods above, and:
|
|
386
|
+
claimSession: async (id, owner) => {
|
|
387
|
+
// One statement, so that two managers never both win: take the session unless a manager
|
|
388
|
+
// whose lease has not expired holds it.
|
|
389
|
+
const row = await db.query(
|
|
390
|
+
`INSERT INTO session_owners (session_id, owner) VALUES ($1, $2)
|
|
391
|
+
ON CONFLICT (session_id) DO UPDATE SET owner = $2
|
|
392
|
+
WHERE session_owners.owner = $2
|
|
393
|
+
OR NOT EXISTS (SELECT 1 FROM manager_leases l
|
|
394
|
+
WHERE l.owner = session_owners.owner AND l.expires_at > now())
|
|
395
|
+
RETURNING (SELECT owner FROM session_owners WHERE session_id = $1) AS previous_owner`,
|
|
396
|
+
[id, owner],
|
|
397
|
+
);
|
|
398
|
+
if (row) return { claimed: true, previousOwner: row.previous_owner };
|
|
399
|
+
const { owner: holder } = await db.query(
|
|
400
|
+
'SELECT owner FROM session_owners WHERE session_id = $1',
|
|
401
|
+
[id],
|
|
402
|
+
);
|
|
403
|
+
return { claimed: false, owner: holder };
|
|
404
|
+
},
|
|
405
|
+
renewLease: (owner, ttlMs) =>
|
|
406
|
+
db.query(
|
|
407
|
+
`INSERT INTO manager_leases (owner, expires_at) VALUES ($1, now() + $2 * interval '1 ms')
|
|
408
|
+
ON CONFLICT (owner) DO UPDATE SET expires_at = excluded.expires_at`,
|
|
409
|
+
[owner, ttlMs],
|
|
410
|
+
),
|
|
411
|
+
releaseSession: (id, owner) =>
|
|
412
|
+
db.query('DELETE FROM session_owners WHERE session_id = $1 AND owner = $2', [id, owner]),
|
|
413
|
+
};
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The lease's expiry is read on the database's clock, the one clock all instances share. `delete()` forgets who holds the session too. Give each manager a `managerId` you can route by — the instance's address, a pod name — to send a request to the instance that runs the session; never two running managers the same.
|
|
327
417
|
|
|
328
418
|
## Reference
|
|
329
419
|
|
|
@@ -333,7 +423,9 @@ Keep one manager per store: at its start, a manager takes the sessions the store
|
|
|
333
423
|
| ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------- |
|
|
334
424
|
| `agent` | required | The agent of a session, from `{ id, metadata }`: called at each start and resume |
|
|
335
425
|
| `sandboxes` | required | `sharedSandbox()`, `sandboxPerSession()` or your own `SessionSandboxes` |
|
|
336
|
-
| `store` | in memory | Where sessions are kept: a `SessionStore
|
|
426
|
+
| `store` | in memory | Where sessions are kept: a `SessionStore`, shared by several managers when it gives leases |
|
|
427
|
+
| `managerId` | `crypto.randomUUID()` | This manager among those sharing the store, named by `SessionOwnedElsewhereError` |
|
|
428
|
+
| `leaseTtlMs` | 30 seconds | How long this manager holds its sessions without renewing its lease, with a store that gives leases |
|
|
337
429
|
| `idleTimeoutMs` | never | Suspend a session idle, or awaiting input, for this long |
|
|
338
430
|
| `closeSuspendedAfterMs` | never | Close a session suspended for this long |
|
|
339
431
|
| `turnTimeoutMs` | never | Cut a turn short after this long |
|
|
@@ -365,11 +457,12 @@ Each has an `isInstance()` that recognizes it whichever copy of the package thre
|
|
|
365
457
|
|
|
366
458
|
- `SessionNotFoundError` (404): no session has this id. Carries `sessionId`.
|
|
367
459
|
- `SessionConflictError` (409): the session cannot do it in its status — a message while `busy` or `awaiting-input`, `continue()` with nothing to answer, anything once `closed` or `interrupted`. Carries `sessionId` and `status`.
|
|
460
|
+
- `SessionOwnedElsewhereError` (409, or a redirect): another manager sharing the store runs the session. A `SessionConflictError` as well; carries `owner`, that manager's `managerId`.
|
|
368
461
|
- `SessionCapacityError` (503): every sandbox slot is taken by a session that is not idle. Carries `capacity`.
|
|
369
462
|
|
|
370
463
|
## Limitations
|
|
371
464
|
|
|
372
|
-
- **One process per store.**
|
|
465
|
+
- **One process per store, unless it gives leases.** Without `claimSession`, `renewLease` and `releaseSession`, the live sessions are held in the memory of one process: run the manager on one instance. With them, a turn under way is still read from the instance that runs it: a client whose request reaches another one gets `SessionOwnedElsewhereError`. And a manager stalled for longer than its lease — a process frozen, not crashed — may find its sessions taken over by another one when it wakes up.
|
|
373
466
|
- **A turn does not survive a crash.** A session the process held when it crashed is `interrupted`; a clean `shutdown()` suspends it instead. Streams that reconnect after a page reload are not supported yet: a reload cuts the turn its request carried when it passed `abortSignal: request.signal`.
|
|
374
467
|
- **Approvals after a resume, with Claude Code.** A resumed Claude Code session (`@ai-sdk/harness-claude-code` up to 1.0.152) sends "Continue." in place of the answers to the approvals it asks afterwards: the tool call is rejected, and asked again ([vercel/ai#22549](https://github.com/vercel/ai/issues/22549)). Approvals work in a session until it is suspended; a session that resumed asks again and again. A turn that was waiting for an approval when its session was suspended resumes without it: `continue()` tells the agent the answers in a message.
|
|
375
468
|
- **No approvals of Codex's own tools.** `@ai-sdk/harness-codex` (up to 1.0.150) runs Codex with approvals turned off and refuses any `permissionMode` but `'allow-all'`, though Codex itself can ask before a command or a patch ([vercel/ai#22550](https://github.com/vercel/ai/issues/22550)). Your own tools can still ask, through `toolApproval`.
|
|
@@ -15,8 +15,25 @@ export interface SessionManagerOptions<METADATA> {
|
|
|
15
15
|
}) => Promise<SessionAgent> | SessionAgent;
|
|
16
16
|
/** How sessions get a sandbox: `sharedSandbox()`, `sandboxPerSession()`, or your own. */
|
|
17
17
|
sandboxes: SessionSandboxes;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Where sessions are kept. Default: in memory, `createMemorySessionStore()`. A store that gives
|
|
20
|
+
* leases (`claimSession`, `renewLease`, `releaseSession`) is shared by several managers: the
|
|
21
|
+
* instances of an application.
|
|
22
|
+
*/
|
|
19
23
|
store?: SessionStore<METADATA>;
|
|
24
|
+
/**
|
|
25
|
+
* This manager, among those sharing the store: what `SessionOwnedElsewhereError.owner` names,
|
|
26
|
+
* to send a request to the instance that runs the session. Default: `crypto.randomUUID()`. Give
|
|
27
|
+
* an id of your own — the instance's address — to route by it; never two running managers the
|
|
28
|
+
* same.
|
|
29
|
+
*/
|
|
30
|
+
managerId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* How long this manager's hold on its sessions lasts without renewal, in milliseconds, with a
|
|
33
|
+
* store that gives leases: renewed three times within it, and what another manager waits, after
|
|
34
|
+
* a crash, before taking its sessions over. Default: 30 seconds.
|
|
35
|
+
*/
|
|
36
|
+
leaseTtlMs?: number;
|
|
20
37
|
/**
|
|
21
38
|
* Suspend a session idle, or awaiting input, for this long, in milliseconds: its harness session
|
|
22
39
|
* stops, its state is kept and its sandbox freed. The next message brings it back. Default:
|
|
@@ -105,6 +122,8 @@ export type SessionEvent<METADATA = unknown> = {
|
|
|
105
122
|
* message to the last, across suspensions and restarts. See {@link createSessionManager}.
|
|
106
123
|
*/
|
|
107
124
|
export interface SessionManager<METADATA = unknown> {
|
|
125
|
+
/** This manager, among those sharing the store. */
|
|
126
|
+
readonly managerId: string;
|
|
108
127
|
/**
|
|
109
128
|
* Opens a session and returns at once, `preparing`: its sandbox and its harness session are
|
|
110
129
|
* opened in the background — minutes the very first time, when the harness is installed in the
|
|
@@ -21,6 +21,37 @@ export interface SessionStore<METADATA = unknown> {
|
|
|
21
21
|
list(): Promise<SessionSummary<METADATA>[]>;
|
|
22
22
|
/** What a suspended session needs to resume. */
|
|
23
23
|
getResumeState(id: string): Promise<HarnessAgentResumeSessionState | undefined>;
|
|
24
|
-
/** Forgets the session and its resume state. */
|
|
24
|
+
/** Forgets the session and its resume state, and who holds it. */
|
|
25
25
|
delete(id: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Optional, with `renewLease` and `releaseSession`: lets several managers share the store — the
|
|
28
|
+
* instances of an application behind a load balancer, or a new version deployed beside the old
|
|
29
|
+
* one. A manager holds each session it runs, and keeps its lease alive while it runs: another
|
|
30
|
+
* manager leaves its sessions alone, and takes them over once its lease expired.
|
|
31
|
+
*
|
|
32
|
+
* Makes `owner` hold the session `id`, in one atomic step (a compare-and-set on a database row),
|
|
33
|
+
* unless another manager whose lease has not expired holds it. The session need not be saved
|
|
34
|
+
* yet: `create()` takes it before writing it.
|
|
35
|
+
*/
|
|
36
|
+
claimSession?(id: string, owner: string): Promise<SessionClaim>;
|
|
37
|
+
/**
|
|
38
|
+
* Keeps the lease of the manager `owner` alive for `ttlMs` milliseconds from now, by the store's
|
|
39
|
+
* clock: what decides whether the sessions it holds may be taken over.
|
|
40
|
+
*/
|
|
41
|
+
renewLease?(owner: string, ttlMs: number): Promise<void>;
|
|
42
|
+
/** Lets go of the session `id`, when `owner` holds it. */
|
|
43
|
+
releaseSession?(id: string, owner: string): Promise<void>;
|
|
26
44
|
}
|
|
45
|
+
/** What `claimSession` made of a claim. */
|
|
46
|
+
export type SessionClaim = {
|
|
47
|
+
claimed: false;
|
|
48
|
+
/** The manager that holds the session, its lease alive. */
|
|
49
|
+
owner: string;
|
|
50
|
+
} | {
|
|
51
|
+
claimed: true;
|
|
52
|
+
/**
|
|
53
|
+
* Who held the session before: `null` when nobody did, the claiming manager itself when it
|
|
54
|
+
* held it already, or a manager whose lease expired.
|
|
55
|
+
*/
|
|
56
|
+
previousOwner: null | string;
|
|
57
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SessionStatus } from '../definitions/session.js';
|
|
2
|
+
import { SessionConflictError } from './session-conflict-error.js';
|
|
3
|
+
/** What `isInstance` looks for, shared by every copy of this package. */
|
|
4
|
+
declare const MARKER: unique symbol;
|
|
5
|
+
/**
|
|
6
|
+
* Another manager sharing the store runs the session: send the request to it — the instance
|
|
7
|
+
* `owner` names —, or answer 409 and let the client try again. A `SessionConflictError` as well.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SessionOwnedElsewhereError extends SessionConflictError {
|
|
10
|
+
/** The manager that runs the session: its `managerId`. */
|
|
11
|
+
readonly owner: string;
|
|
12
|
+
private readonly [MARKER];
|
|
13
|
+
constructor(sessionId: string, status: SessionStatus,
|
|
14
|
+
/** The manager that runs the session: its `managerId`. */
|
|
15
|
+
owner: string);
|
|
16
|
+
/** Whether `error` is one, whichever copy of the package threw it. */
|
|
17
|
+
static isInstance(error: unknown): error is SessionOwnedElsewhereError;
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SessionConflictError } from './session-conflict-error.js';
|
|
2
|
+
/** What `isInstance` looks for, shared by every copy of this package. */
|
|
3
|
+
const MARKER = Symbol.for('ai-sdk-harness-sessions.SessionOwnedElsewhereError');
|
|
4
|
+
/**
|
|
5
|
+
* Another manager sharing the store runs the session: send the request to it — the instance
|
|
6
|
+
* `owner` names —, or answer 409 and let the client try again. A `SessionConflictError` as well.
|
|
7
|
+
*/
|
|
8
|
+
export class SessionOwnedElsewhereError extends SessionConflictError {
|
|
9
|
+
owner;
|
|
10
|
+
[MARKER] = true;
|
|
11
|
+
constructor(sessionId, status,
|
|
12
|
+
/** The manager that runs the session: its `managerId`. */
|
|
13
|
+
owner) {
|
|
14
|
+
super(sessionId, status, `The session ${sessionId} is run by another manager, ${owner}.`);
|
|
15
|
+
this.owner = owner;
|
|
16
|
+
this.name = 'SessionOwnedElsewhereError';
|
|
17
|
+
}
|
|
18
|
+
/** Whether `error` is one, whichever copy of the package threw it. */
|
|
19
|
+
static isInstance(error) {
|
|
20
|
+
return typeof error === 'object' && error !== null && MARKER in error;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,10 +3,11 @@ export type { ContinueOptions, SendOptions, SessionEvent, SessionManager, Sessio
|
|
|
3
3
|
export type { SessionSandboxes, SessionSandboxLease } from './definitions/sandboxes.js';
|
|
4
4
|
export { LIVE_STATUSES, SESSION_STATUSES } from './definitions/session.js';
|
|
5
5
|
export type { PendingApproval, PendingInput, PendingToolCall, SessionRecord, SessionStatus, SessionSummary, SessionUsage, } from './definitions/session.js';
|
|
6
|
-
export type { SessionStore } from './definitions/store.js';
|
|
6
|
+
export type { SessionClaim, SessionStore } from './definitions/store.js';
|
|
7
7
|
export { SessionCapacityError } from './errors/session-capacity-error.js';
|
|
8
8
|
export { SessionConflictError } from './errors/session-conflict-error.js';
|
|
9
9
|
export { SessionNotFoundError } from './errors/session-not-found-error.js';
|
|
10
|
+
export { SessionOwnedElsewhereError } from './errors/session-owned-elsewhere-error.js';
|
|
10
11
|
export { createSessionManager } from './manager/session-manager.js';
|
|
11
12
|
export { sandboxPerSession } from './sandboxes/sandbox-per-session.js';
|
|
12
13
|
export type { SandboxPerSessionOptions } from './sandboxes/sandbox-per-session.js';
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { LIVE_STATUSES, SESSION_STATUSES } from './definitions/session.js';
|
|
|
2
2
|
export { SessionCapacityError } from './errors/session-capacity-error.js';
|
|
3
3
|
export { SessionConflictError } from './errors/session-conflict-error.js';
|
|
4
4
|
export { SessionNotFoundError } from './errors/session-not-found-error.js';
|
|
5
|
+
export { SessionOwnedElsewhereError } from './errors/session-owned-elsewhere-error.js';
|
|
5
6
|
export { createSessionManager } from './manager/session-manager.js';
|
|
6
7
|
export { sandboxPerSession } from './sandboxes/sandbox-per-session.js';
|
|
7
8
|
export { sharedSandbox } from './sandboxes/shared-sandbox.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SessionClaim, SessionStore } from '../definitions/store.js';
|
|
2
|
+
type Report = (action: string, sessionId?: string) => (error: unknown) => void;
|
|
3
|
+
/** The methods of a store that shares its sessions between managers. */
|
|
4
|
+
type LeasingStore = Pick<SessionStore<unknown>, 'claimSession' | 'releaseSession' | 'renewLease'>;
|
|
5
|
+
export interface SessionLeasesOptions {
|
|
6
|
+
store: LeasingStore;
|
|
7
|
+
/** This manager. */
|
|
8
|
+
owner: string;
|
|
9
|
+
ttlMs: number;
|
|
10
|
+
report: Report;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The sessions a manager holds in a store it shares with other managers, and its lease that
|
|
14
|
+
* keeps them: renewed in the background, checked by the store when another manager claims one. A
|
|
15
|
+
* store without leases has one manager: every claim succeeds, and nothing is renewed.
|
|
16
|
+
*/
|
|
17
|
+
export declare class SessionLeases {
|
|
18
|
+
readonly owner: string;
|
|
19
|
+
/** Whether the store shares its sessions between managers. */
|
|
20
|
+
readonly shared: boolean;
|
|
21
|
+
private readonly store;
|
|
22
|
+
private readonly ttlMs;
|
|
23
|
+
private readonly report;
|
|
24
|
+
private renewal;
|
|
25
|
+
constructor({ store, owner, ttlMs, report }: SessionLeasesOptions);
|
|
26
|
+
/**
|
|
27
|
+
* Takes the lease, then keeps it alive until `stop()`; `onLeaseRenewed` runs once per lease,
|
|
28
|
+
* after a renewal.
|
|
29
|
+
*/
|
|
30
|
+
start(onLeaseRenewed: () => void): Promise<void>;
|
|
31
|
+
stop(): void;
|
|
32
|
+
/** Makes this manager hold the session, unless another one does. */
|
|
33
|
+
claim(id: string): Promise<SessionClaim>;
|
|
34
|
+
/** Lets go of the session; a failure is reported, the lease expiring lets go of it anyway. */
|
|
35
|
+
release(id: string): Promise<void>;
|
|
36
|
+
private renew;
|
|
37
|
+
}
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** How often a manager renews its lease: three times within its life. */
|
|
2
|
+
const RENEWALS_PER_LEASE = 3;
|
|
3
|
+
/** Whether the store gives its lease methods: all three, or none. */
|
|
4
|
+
function sharesSessions(store) {
|
|
5
|
+
const given = [
|
|
6
|
+
typeof store.claimSession,
|
|
7
|
+
typeof store.renewLease,
|
|
8
|
+
typeof store.releaseSession,
|
|
9
|
+
].filter((type) => type === 'function').length;
|
|
10
|
+
if (given !== 0 && given !== 3) {
|
|
11
|
+
throw new TypeError('A session store shares its sessions with claimSession, renewLease and releaseSession, all three.');
|
|
12
|
+
}
|
|
13
|
+
return given === 3;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The sessions a manager holds in a store it shares with other managers, and its lease that
|
|
17
|
+
* keeps them: renewed in the background, checked by the store when another manager claims one. A
|
|
18
|
+
* store without leases has one manager: every claim succeeds, and nothing is renewed.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionLeases {
|
|
21
|
+
owner;
|
|
22
|
+
/** Whether the store shares its sessions between managers. */
|
|
23
|
+
shared;
|
|
24
|
+
store;
|
|
25
|
+
ttlMs;
|
|
26
|
+
report;
|
|
27
|
+
renewal;
|
|
28
|
+
constructor({ store, owner, ttlMs, report }) {
|
|
29
|
+
this.shared = sharesSessions(store);
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.owner = owner;
|
|
32
|
+
this.ttlMs = ttlMs;
|
|
33
|
+
this.report = report;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Takes the lease, then keeps it alive until `stop()`; `onLeaseRenewed` runs once per lease,
|
|
37
|
+
* after a renewal.
|
|
38
|
+
*/
|
|
39
|
+
async start(onLeaseRenewed) {
|
|
40
|
+
if (!this.shared)
|
|
41
|
+
return;
|
|
42
|
+
await this.renew();
|
|
43
|
+
let renewals = 0;
|
|
44
|
+
this.renewal = setInterval(() => {
|
|
45
|
+
void this.renew().then(() => {
|
|
46
|
+
renewals += 1;
|
|
47
|
+
if (renewals % RENEWALS_PER_LEASE === 0)
|
|
48
|
+
onLeaseRenewed();
|
|
49
|
+
}, this.report('lease'));
|
|
50
|
+
}, this.ttlMs / RENEWALS_PER_LEASE);
|
|
51
|
+
this.renewal.unref();
|
|
52
|
+
}
|
|
53
|
+
stop() {
|
|
54
|
+
clearInterval(this.renewal);
|
|
55
|
+
}
|
|
56
|
+
/** Makes this manager hold the session, unless another one does. */
|
|
57
|
+
claim(id) {
|
|
58
|
+
const claimed = this.shared ? this.store.claimSession?.(id, this.owner) : undefined;
|
|
59
|
+
return claimed ?? Promise.resolve({ claimed: true, previousOwner: this.owner });
|
|
60
|
+
}
|
|
61
|
+
/** Lets go of the session; a failure is reported, the lease expiring lets go of it anyway. */
|
|
62
|
+
async release(id) {
|
|
63
|
+
if (!this.shared)
|
|
64
|
+
return;
|
|
65
|
+
await this.store.releaseSession?.(id, this.owner)?.catch(this.report('release', id));
|
|
66
|
+
}
|
|
67
|
+
async renew() {
|
|
68
|
+
await this.store.renewLease?.(this.owner, this.ttlMs);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -12,7 +12,9 @@ import type { SessionManager, SessionManagerOptions } from '../definitions/manag
|
|
|
12
12
|
* a session the process left live without it is `interrupted` at the next start.
|
|
13
13
|
* 6. `close()` ends a session for good.
|
|
14
14
|
*
|
|
15
|
-
* Every change is written to the store and sent to the subscribers.
|
|
16
|
-
*
|
|
15
|
+
* Every change is written to the store and sent to the subscribers. Several managers share a store
|
|
16
|
+
* that gives them leases (`claimSession`, `renewLease`, `releaseSession`): each runs the sessions it
|
|
17
|
+
* holds. Without them, keep one manager per store: a second one would take the sessions of the
|
|
18
|
+
* first for those of a crashed process.
|
|
17
19
|
*/
|
|
18
20
|
export declare function createSessionManager<METADATA = unknown>(options: SessionManagerOptions<METADATA>): SessionManager<METADATA>;
|
|
@@ -4,12 +4,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { SessionCapacityError } from '../errors/session-capacity-error.js';
|
|
5
5
|
import { SessionConflictError } from '../errors/session-conflict-error.js';
|
|
6
6
|
import { SessionNotFoundError } from '../errors/session-not-found-error.js';
|
|
7
|
+
import { SessionOwnedElsewhereError } from '../errors/session-owned-elsewhere-error.js';
|
|
7
8
|
import { createMemorySessionStore } from '../store/memory-session-store.js';
|
|
8
9
|
import { decidedChunks, decidedResponses, signatureOf, verdictOf, } from '../turns/approval-answers.js';
|
|
9
10
|
import { describeResponses, pendingInputOf, responsesFrom, withResponses, } from '../turns/pending-input.js';
|
|
10
11
|
import { streamTurn } from '../turns/turn-stream.js';
|
|
11
12
|
import { isLive, isReady, liveSession, messageOf, newRecord, summaryOf, textOf, } from './live-session.js';
|
|
12
13
|
import { SessionJournal } from './session-journal.js';
|
|
14
|
+
import { SessionLeases } from './session-leases.js';
|
|
13
15
|
const INTERRUPTED_BY_CRASH = 'The application stopped without suspending the session.';
|
|
14
16
|
const CUT_BY_SHUTDOWN = 'The turn was cut short: the application stopped. Send the message again.';
|
|
15
17
|
const FAILED_BY_SHUTDOWN = 'The application stopped before the session was ready.';
|
|
@@ -17,6 +19,8 @@ const NOT_STOPPED = 'The harness session could not be stopped cleanly.';
|
|
|
17
19
|
const NOTHING_TO_RESUME = 'Nothing was kept to resume the session from.';
|
|
18
20
|
/** How long a shutdown waits for a turn it cut short to wind down. */
|
|
19
21
|
const WIND_DOWN_MS = 10_000;
|
|
22
|
+
/** How long a manager's lease on its sessions lasts, unless renewed. */
|
|
23
|
+
const LEASE_TTL_MS = 30_000;
|
|
20
24
|
/** How many approvals `approve` answers in one turn, at most, before leaving them to a person. */
|
|
21
25
|
const MAX_ANSWERED_PER_TURN = 200;
|
|
22
26
|
/**
|
|
@@ -63,6 +67,17 @@ function withoutUnfinishedTurn(state) {
|
|
|
63
67
|
const defaultReport = (error, { action, sessionId }) =>
|
|
64
68
|
// eslint-disable-next-line no-console -- the default, until the application passes onError
|
|
65
69
|
console.error(`ai-sdk-harness-sessions: ${action}${sessionId ? ` ${sessionId}` : ''}:`, error);
|
|
70
|
+
function settingsOf(options) {
|
|
71
|
+
return {
|
|
72
|
+
idleTimeoutMs: options.idleTimeoutMs ?? 0,
|
|
73
|
+
closeSuspendedAfterMs: options.closeSuspendedAfterMs ?? 0,
|
|
74
|
+
turnTimeoutMs: options.turnTimeoutMs ?? 0,
|
|
75
|
+
suspendIdleWhenFull: options.suspendIdleWhenFull ?? true,
|
|
76
|
+
generateId: options.generateId ?? randomUUID,
|
|
77
|
+
errorMessage: options.errorMessage ?? getHarnessErrorMessage,
|
|
78
|
+
onError: options.onError ?? defaultReport,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
66
81
|
/**
|
|
67
82
|
* Manages the sessions of an application with its coding agents, `HarnessAgent`s of any harness:
|
|
68
83
|
*
|
|
@@ -76,17 +91,23 @@ console.error(`ai-sdk-harness-sessions: ${action}${sessionId ? ` ${sessionId}` :
|
|
|
76
91
|
* a session the process left live without it is `interrupted` at the next start.
|
|
77
92
|
* 6. `close()` ends a session for good.
|
|
78
93
|
*
|
|
79
|
-
* Every change is written to the store and sent to the subscribers.
|
|
80
|
-
*
|
|
94
|
+
* Every change is written to the store and sent to the subscribers. Several managers share a store
|
|
95
|
+
* that gives them leases (`claimSession`, `renewLease`, `releaseSession`): each runs the sessions it
|
|
96
|
+
* holds. Without them, keep one manager per store: a second one would take the sessions of the
|
|
97
|
+
* first for those of a crashed process.
|
|
81
98
|
*/
|
|
82
99
|
export function createSessionManager(options) {
|
|
83
100
|
return new HarnessSessionManager(options);
|
|
84
101
|
}
|
|
85
102
|
class HarnessSessionManager {
|
|
86
103
|
options;
|
|
104
|
+
get managerId() {
|
|
105
|
+
return this.leases.owner;
|
|
106
|
+
}
|
|
87
107
|
store;
|
|
88
108
|
settings;
|
|
89
109
|
journal;
|
|
110
|
+
leases;
|
|
90
111
|
live = new Map();
|
|
91
112
|
/** Starts run one at a time: the first one installs the harness in the sandbox. */
|
|
92
113
|
starting = Promise.resolve();
|
|
@@ -96,17 +117,18 @@ class HarnessSessionManager {
|
|
|
96
117
|
constructor(options) {
|
|
97
118
|
this.options = options;
|
|
98
119
|
this.store = options.store ?? createMemorySessionStore();
|
|
99
|
-
this.settings =
|
|
100
|
-
idleTimeoutMs: options.idleTimeoutMs ?? 0,
|
|
101
|
-
closeSuspendedAfterMs: options.closeSuspendedAfterMs ?? 0,
|
|
102
|
-
turnTimeoutMs: options.turnTimeoutMs ?? 0,
|
|
103
|
-
suspendIdleWhenFull: options.suspendIdleWhenFull ?? true,
|
|
104
|
-
generateId: options.generateId ?? randomUUID,
|
|
105
|
-
errorMessage: options.errorMessage ?? getHarnessErrorMessage,
|
|
106
|
-
onError: options.onError ?? defaultReport,
|
|
107
|
-
};
|
|
120
|
+
this.settings = settingsOf(options);
|
|
108
121
|
this.journal = new SessionJournal(this.store, this.report);
|
|
109
|
-
this.
|
|
122
|
+
this.leases = new SessionLeases({
|
|
123
|
+
store: this.store,
|
|
124
|
+
owner: options.managerId ?? randomUUID(),
|
|
125
|
+
ttlMs: options.leaseTtlMs ?? LEASE_TTL_MS,
|
|
126
|
+
report: this.report,
|
|
127
|
+
});
|
|
128
|
+
this.recovered = this.leases
|
|
129
|
+
.start(() => void this.recoverOrphans().catch(this.report('recover')))
|
|
130
|
+
.then(() => this.recover())
|
|
131
|
+
.catch(this.report('recover'));
|
|
110
132
|
const { closeSuspendedAfterMs } = this.settings;
|
|
111
133
|
if (closeSuspendedAfterMs > 0) {
|
|
112
134
|
this.sweeper = setInterval(() => void this.sweep().catch(this.report('sweep')), Math.min(closeSuspendedAfterMs, 60_000));
|
|
@@ -118,7 +140,13 @@ class HarnessSessionManager {
|
|
|
118
140
|
const existing = this.live.get(id)?.record ?? (await this.store.get(id));
|
|
119
141
|
if (existing !== undefined)
|
|
120
142
|
throw this.conflict(existing, `A session ${id} already exists.`);
|
|
121
|
-
await this.
|
|
143
|
+
const claim = await this.leases.claim(id);
|
|
144
|
+
if (!claim.claimed)
|
|
145
|
+
throw new SessionOwnedElsewhereError(id, 'preparing', claim.owner);
|
|
146
|
+
await this.makeRoom().catch(async (error) => {
|
|
147
|
+
await this.leases.release(id);
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
122
150
|
const session = liveSession(newRecord(id, metadata));
|
|
123
151
|
this.live.set(id, session);
|
|
124
152
|
await this.transition(session, 'preparing');
|
|
@@ -190,23 +218,26 @@ class HarnessSessionManager {
|
|
|
190
218
|
const live = this.live.get(id);
|
|
191
219
|
if (live !== undefined)
|
|
192
220
|
return this.changeMetadata(live.record, change(live.record.metadata));
|
|
193
|
-
const kept = await this.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return this.changeMetadata(
|
|
202
|
-
|
|
221
|
+
const { record: kept, release } = await this.hold(id);
|
|
222
|
+
try {
|
|
223
|
+
const resumeState = kept.status === 'suspended' ? await this.store.getResumeState(id) : undefined;
|
|
224
|
+
const changed = change(kept.metadata);
|
|
225
|
+
// Brought back while the store was read: the live session is the one to change.
|
|
226
|
+
const racing = this.live.get(id);
|
|
227
|
+
if (racing !== undefined)
|
|
228
|
+
return await this.changeMetadata(racing.record, changed);
|
|
229
|
+
return await this.changeMetadata(kept, changed, resumeState);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
await release();
|
|
233
|
+
}
|
|
203
234
|
};
|
|
204
235
|
interrupt = async (id) => {
|
|
205
236
|
await this.recovered;
|
|
206
237
|
const session = this.live.get(id);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
238
|
+
// Run by another manager, it throws: the request is for that one.
|
|
239
|
+
if (session === undefined)
|
|
240
|
+
await (await this.hold(id)).release();
|
|
210
241
|
const turn = session?.turn;
|
|
211
242
|
if (turn === undefined)
|
|
212
243
|
return;
|
|
@@ -217,9 +248,8 @@ class HarnessSessionManager {
|
|
|
217
248
|
await this.ready();
|
|
218
249
|
const session = this.live.get(id);
|
|
219
250
|
if (session === undefined) {
|
|
220
|
-
const kept = await this.
|
|
221
|
-
|
|
222
|
-
throw new SessionNotFoundError(id);
|
|
251
|
+
const { record: kept, release } = await this.hold(id);
|
|
252
|
+
await release();
|
|
223
253
|
if (kept.status === 'suspended')
|
|
224
254
|
return summaryOf(kept);
|
|
225
255
|
throw this.conflict(kept);
|
|
@@ -241,10 +271,13 @@ class HarnessSessionManager {
|
|
|
241
271
|
await session.ending;
|
|
242
272
|
return summaryOf(session.record);
|
|
243
273
|
}
|
|
244
|
-
const kept = await this.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
274
|
+
const { record: kept, release } = await this.hold(id);
|
|
275
|
+
try {
|
|
276
|
+
return await this.closeKept(kept);
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await release();
|
|
280
|
+
}
|
|
248
281
|
};
|
|
249
282
|
delete = async (id) => {
|
|
250
283
|
await this.close(id);
|
|
@@ -256,6 +289,7 @@ class HarnessSessionManager {
|
|
|
256
289
|
return;
|
|
257
290
|
this.stopped = true;
|
|
258
291
|
clearInterval(this.sweeper);
|
|
292
|
+
this.leases.stop();
|
|
259
293
|
await Promise.all([...this.live.values()].map((session) => this.shutdownSession(session)));
|
|
260
294
|
await this.options.sandboxes.shutdown?.().catch(this.report('shutdown'));
|
|
261
295
|
await this.journal.flush();
|
|
@@ -267,19 +301,69 @@ class HarnessSessionManager {
|
|
|
267
301
|
await this.recovered;
|
|
268
302
|
}
|
|
269
303
|
/**
|
|
270
|
-
*
|
|
271
|
-
* suspending it: it cannot
|
|
304
|
+
* At the start: a session still live in the store, that no other manager runs, was live when
|
|
305
|
+
* its manager stopped without suspending it — this one, before, or one that crashed: it cannot
|
|
306
|
+
* be brought back.
|
|
272
307
|
*/
|
|
273
308
|
async recover() {
|
|
274
309
|
for (const { id, status } of await this.store.list()) {
|
|
275
310
|
if (!isLive(status))
|
|
276
311
|
continue;
|
|
312
|
+
const claim = await this.leases.claim(id);
|
|
313
|
+
if (!claim.claimed)
|
|
314
|
+
continue;
|
|
277
315
|
const record = await this.store.get(id);
|
|
278
|
-
if (record === undefined)
|
|
316
|
+
if (record === undefined || !isLive(record.status)) {
|
|
317
|
+
await this.leases.release(id);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
await this.interruptOrphan(record);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Then, once per lease: the sessions of the managers sharing the store whose lease expired —
|
|
325
|
+
* they crashed — are interrupted.
|
|
326
|
+
*/
|
|
327
|
+
async recoverOrphans() {
|
|
328
|
+
if (this.stopped)
|
|
329
|
+
return;
|
|
330
|
+
for (const { id, status } of await this.store.list()) {
|
|
331
|
+
if (!isLive(status) || this.live.has(id))
|
|
279
332
|
continue;
|
|
280
|
-
await this.
|
|
333
|
+
await this.hold(id).then(({ release }) => release(), () => undefined);
|
|
281
334
|
}
|
|
282
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* The session `id` as the store keeps it, held by this manager until `release()`: what to read
|
|
338
|
+
* and change of a session no manager runs. Throws `SessionOwnedElsewhereError` when another
|
|
339
|
+
* manager runs it. A session the store keeps live although no manager runs it any more — its
|
|
340
|
+
* manager crashed — is `interrupted` first.
|
|
341
|
+
*/
|
|
342
|
+
async hold(id) {
|
|
343
|
+
if ((await this.store.get(id)) === undefined)
|
|
344
|
+
throw new SessionNotFoundError(id);
|
|
345
|
+
const claim = await this.leases.claim(id);
|
|
346
|
+
// Read again once held: what the manager that held it last wrote.
|
|
347
|
+
const record = await this.store.get(id);
|
|
348
|
+
if (!claim.claimed) {
|
|
349
|
+
throw new SessionOwnedElsewhereError(id, record?.status ?? 'preparing', claim.owner);
|
|
350
|
+
}
|
|
351
|
+
const ours = claim.previousOwner === this.leases.owner;
|
|
352
|
+
const release = () => (ours ? Promise.resolve() : this.leases.release(id));
|
|
353
|
+
if (record === undefined) {
|
|
354
|
+
await release();
|
|
355
|
+
throw new SessionNotFoundError(id);
|
|
356
|
+
}
|
|
357
|
+
if (!ours && isLive(record.status))
|
|
358
|
+
return { record: await this.interruptOrphan(record), release };
|
|
359
|
+
return { record, release };
|
|
360
|
+
}
|
|
361
|
+
/** Interrupts a session left live by a manager that stopped without suspending it. */
|
|
362
|
+
async interruptOrphan(record) {
|
|
363
|
+
const session = liveSession(record);
|
|
364
|
+
await this.transition(session, 'interrupted', { error: INTERRUPTED_BY_CRASH });
|
|
365
|
+
return session.record;
|
|
366
|
+
}
|
|
283
367
|
/** The one place a session changes status: it is written, unless told not to, and sent out. */
|
|
284
368
|
transition(session, status, { error, persist = true, resumeState } = {}) {
|
|
285
369
|
const { record } = session;
|
|
@@ -290,7 +374,9 @@ class HarnessSessionManager {
|
|
|
290
374
|
else
|
|
291
375
|
record.error = error;
|
|
292
376
|
this.journal.updated(record);
|
|
293
|
-
|
|
377
|
+
const written = persist ? this.journal.write(record, resumeState) : Promise.resolve();
|
|
378
|
+
// Once it is written: the manager that claims it next reads where it stopped.
|
|
379
|
+
return isLive(status) ? written : written.then(() => this.leases.release(record.id));
|
|
294
380
|
}
|
|
295
381
|
/** Writes new metadata, without counting it as activity: `updatedAt` stays. */
|
|
296
382
|
async changeMetadata(record, metadata, resumeState) {
|
|
@@ -377,8 +463,9 @@ class HarnessSessionManager {
|
|
|
377
463
|
this.report(resumeFrom === undefined ? 'start' : 'resume', id)(error);
|
|
378
464
|
if (this.live.get(id) === session)
|
|
379
465
|
this.live.delete(id);
|
|
380
|
-
|
|
381
|
-
//
|
|
466
|
+
// A resume that failed keeps what it resumes from — its state, its sandbox and its files —:
|
|
467
|
+
// the next message tries again.
|
|
468
|
+
await (resumeFrom === undefined ? this.teardown(session) : this.release(session));
|
|
382
469
|
await (resumeFrom === undefined
|
|
383
470
|
? this.transition(session, 'failed', { error: messageOf(error) })
|
|
384
471
|
: this.transition(session, 'suspended', {
|
|
@@ -395,6 +482,13 @@ class HarnessSessionManager {
|
|
|
395
482
|
await handle?.destroy().catch(this.report('destroy', record.id));
|
|
396
483
|
await lease?.close().catch(this.report('close', record.id));
|
|
397
484
|
}
|
|
485
|
+
/** Hands the sandbox of a session that never started back, keeping its files for a resume. */
|
|
486
|
+
async release(session) {
|
|
487
|
+
const { lease, record } = session;
|
|
488
|
+
session.lease = undefined;
|
|
489
|
+
clearTimeout(session.idleTimer);
|
|
490
|
+
await lease?.suspend().catch(this.report('suspend', record.id));
|
|
491
|
+
}
|
|
398
492
|
/**
|
|
399
493
|
* Suspends the session: its harness session stops, keeping what it needs to resume, and its
|
|
400
494
|
* sandbox is freed.
|
|
@@ -423,12 +517,16 @@ class HarnessSessionManager {
|
|
|
423
517
|
async openSession(id) {
|
|
424
518
|
let session = this.live.get(id);
|
|
425
519
|
if (session === undefined) {
|
|
426
|
-
const kept = await this.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
520
|
+
const { record: kept, release } = await this.hold(id);
|
|
521
|
+
try {
|
|
522
|
+
if (kept.status !== 'suspended' && kept.status !== 'failed')
|
|
523
|
+
throw this.conflict(kept);
|
|
524
|
+
session = await this.revive(kept);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
await release();
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
432
530
|
}
|
|
433
531
|
await session.ready.catch(() => undefined);
|
|
434
532
|
if (this.live.get(id) !== session || !isReady(session))
|
|
@@ -484,6 +582,10 @@ class HarnessSessionManager {
|
|
|
484
582
|
await this.transition(session, 'busy');
|
|
485
583
|
const settle = async (failure) => {
|
|
486
584
|
clearTimeout(timer);
|
|
585
|
+
// Cut short by an interrupt, a timeout or the caller: before the turn is over, so that a
|
|
586
|
+
// close waits for it, and finds the harness session to destroy.
|
|
587
|
+
if (abort.signal.aborted || callerSignal?.aborted === true)
|
|
588
|
+
await this.renew(session, handle);
|
|
487
589
|
await this.settleTurn(session, handle, failure);
|
|
488
590
|
finish();
|
|
489
591
|
};
|
|
@@ -583,7 +685,7 @@ class HarnessSessionManager {
|
|
|
583
685
|
// Closed, suspended or shut down meanwhile: whoever did it writes the session.
|
|
584
686
|
if (this.live.get(record.id) !== session || record.status !== 'busy')
|
|
585
687
|
return;
|
|
586
|
-
const unfinished = handle.hasUnfinishedTurn();
|
|
688
|
+
const unfinished = session.lostTurn === true || handle.hasUnfinishedTurn();
|
|
587
689
|
if (unfinished)
|
|
588
690
|
record.pendingInput = session.paused ?? pendingInputOf(record.messages);
|
|
589
691
|
else
|
|
@@ -594,10 +696,58 @@ class HarnessSessionManager {
|
|
|
594
696
|
});
|
|
595
697
|
this.scheduleIdle(session);
|
|
596
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* Starts the harness session of a turn cut short again, in the same sandbox, from where it
|
|
701
|
+
* stopped. The runtime goes on with the turn for a while after it is told to stop, and a harness
|
|
702
|
+
* session sends what it still says to the next turn: a stray `finish` ends that turn before it
|
|
703
|
+
* starts, and its answer is lost. A session that cannot start again is `suspended`, resumed by
|
|
704
|
+
* the next message, or `interrupted` when its harness session could not be stopped.
|
|
705
|
+
*/
|
|
706
|
+
async renew(session, handle) {
|
|
707
|
+
const { id, status } = session.record;
|
|
708
|
+
// Closed, suspended or shut down meanwhile: whoever did it stops the harness session.
|
|
709
|
+
if (this.live.get(id) !== session || status !== 'busy')
|
|
710
|
+
return;
|
|
711
|
+
const { agent, lease } = session;
|
|
712
|
+
// Started again without the turn it paused in: `continue()` tells the agent the answers.
|
|
713
|
+
session.lostTurn = handle.hasUnfinishedTurn();
|
|
714
|
+
session.handle = undefined;
|
|
715
|
+
const stopped = await handle.stop().catch((error) => {
|
|
716
|
+
this.report('stop', id)(error);
|
|
717
|
+
return undefined;
|
|
718
|
+
});
|
|
719
|
+
if (stopped === undefined || agent === undefined || lease === undefined) {
|
|
720
|
+
if (this.live.get(id) === session)
|
|
721
|
+
this.live.delete(id);
|
|
722
|
+
await this.release(session);
|
|
723
|
+
await this.transition(session, 'interrupted', { error: NOT_STOPPED });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const resumeFrom = withoutUnfinishedTurn(stopped);
|
|
727
|
+
try {
|
|
728
|
+
session.handle = await agent.createSession({
|
|
729
|
+
sessionId: id,
|
|
730
|
+
sandboxSession: lease.sandboxSession,
|
|
731
|
+
resumeFrom,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
this.report('resume', id)(error);
|
|
736
|
+
if (this.live.get(id) === session)
|
|
737
|
+
this.live.delete(id);
|
|
738
|
+
await this.release(session);
|
|
739
|
+
await this.transition(session, 'suspended', {
|
|
740
|
+
error: messageOf(error),
|
|
741
|
+
resumeState: resumeFrom,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
597
745
|
async closeKept(kept) {
|
|
598
746
|
if (kept.status === 'closed')
|
|
599
747
|
return summaryOf(kept);
|
|
600
|
-
|
|
748
|
+
// A suspended session keeps its sandbox for its resume, an interrupted one because nothing freed it: the
|
|
749
|
+
// process stopped while the session held it, or its harness session could not be stopped.
|
|
750
|
+
if (kept.status === 'suspended' || kept.status === 'interrupted') {
|
|
601
751
|
await this.options.sandboxes.discard?.(kept.id).catch(this.report('discard', kept.id));
|
|
602
752
|
}
|
|
603
753
|
const session = liveSession(kept);
|
|
@@ -611,9 +761,13 @@ class HarnessSessionManager {
|
|
|
611
761
|
for (const { id, status, updatedAt } of await this.store.list()) {
|
|
612
762
|
if (status !== 'suspended' || updatedAt >= cutoff || this.live.has(id))
|
|
613
763
|
continue;
|
|
614
|
-
const
|
|
615
|
-
if (
|
|
764
|
+
const held = await this.hold(id).catch(() => undefined);
|
|
765
|
+
if (held === undefined)
|
|
766
|
+
continue;
|
|
767
|
+
const { record: kept, release } = held;
|
|
768
|
+
if (kept.status === 'suspended' && !this.live.has(id))
|
|
616
769
|
await this.closeKept(kept);
|
|
770
|
+
await release();
|
|
617
771
|
}
|
|
618
772
|
}
|
|
619
773
|
/** Closes a live session, cutting its turn short. */
|
|
@@ -5,8 +5,8 @@ export type SandboxView = HarnessV1NetworkSandboxSession & {
|
|
|
5
5
|
release(): PromiseLike<void>;
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
|
-
* A sandbox that gives views of itself with ports of their own: `ai-sdk-sandbox-sbx
|
|
9
|
-
* `ai-sdk-sandbox-cloud-run` sessions are.
|
|
8
|
+
* A sandbox that gives views of itself with ports of their own: `ai-sdk-sandbox-sbx`,
|
|
9
|
+
* `ai-sdk-sandbox-cloud-run` and `ai-sdk-sandbox-runtime` sessions are.
|
|
10
10
|
*/
|
|
11
11
|
export type ForkableSandbox = HarnessV1NetworkSandboxSession & {
|
|
12
12
|
fork(options: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-sdk-harness-sessions",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Sessions for AI SDK harness agents: the lifecycle of conversations with Claude Code, Codex and friends — one turn at a time, suspended when idle, resumed on the next message and after a restart, several of them sharing one sandbox.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -47,20 +47,20 @@
|
|
|
47
47
|
"ai": "^7.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@ai-sdk/harness": "^1.0.
|
|
51
|
-
"@ai-sdk/harness-claude-code": "^1.0.
|
|
52
|
-
"@ai-sdk/harness-codex": "^1.0.
|
|
50
|
+
"@ai-sdk/harness": "^1.0.147",
|
|
51
|
+
"@ai-sdk/harness-claude-code": "^1.0.151",
|
|
52
|
+
"@ai-sdk/harness-codex": "^1.0.149",
|
|
53
53
|
"@types/node": "^24.13.5",
|
|
54
54
|
"@vitest/coverage-v8": "^5.0.3",
|
|
55
|
-
"ai": "^7.0.
|
|
55
|
+
"ai": "^7.0.136",
|
|
56
56
|
"typescript": "^6.0.3",
|
|
57
57
|
"vitest": "^5.0.3",
|
|
58
|
-
"@repo/
|
|
58
|
+
"@repo/build": "0.0.0",
|
|
59
59
|
"@repo/prettier-config": "0.0.0",
|
|
60
|
+
"@repo/eslint-config": "0.0.0",
|
|
60
61
|
"@repo/typescript-config": "0.0.0",
|
|
61
62
|
"@repo/vitest-config": "0.0.0",
|
|
62
|
-
"ai-sdk-sandbox-sbx": "0.1.
|
|
63
|
-
"@repo/build": "0.0.0"
|
|
63
|
+
"ai-sdk-sandbox-sbx": "0.1.2"
|
|
64
64
|
},
|
|
65
65
|
"author": {
|
|
66
66
|
"name": "Fabien Pasquet",
|