tinker-agent 2.13.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import { SessionStore } from "../session/session-store";
|
|
2
|
+
import type { SessionLease } from "../session/session-lock";
|
|
3
|
+
import { runtimeIdFactory } from "../ids/runtime-id";
|
|
4
|
+
import { encodeFailure } from "../remote/failures";
|
|
5
|
+
import type { ClientModelCatalog } from "../client/model-catalog";
|
|
6
|
+
import {
|
|
7
|
+
isSessionOperation,
|
|
8
|
+
type SessionOperationResult,
|
|
9
|
+
} from "../remote/session-operations";
|
|
10
|
+
import { TuiProjectionStore } from "../tui/tui-projection-store";
|
|
11
|
+
import { ResumeProjectionReader } from "../session/resume-projection";
|
|
12
|
+
import type { RemoteTuiSnapshot } from "../remote/tui-protocol";
|
|
1
13
|
import { randomUUID } from "node:crypto";
|
|
2
14
|
import type { RuntimeSession } from "./runtime-session";
|
|
3
15
|
import type {
|
|
@@ -21,20 +33,42 @@ import {
|
|
|
21
33
|
import { type ManagedSessionRecord, RemoteServiceStore } from "../remote/service-store";
|
|
22
34
|
import { RemoteSyncHub } from "../remote/sync-hub";
|
|
23
35
|
|
|
24
|
-
export type HostedRuntimeFactory = (input: {
|
|
36
|
+
export type HostedRuntimeFactory = ((input: {
|
|
25
37
|
record: ManagedSessionRecord;
|
|
38
|
+
lease?: SessionLease;
|
|
26
39
|
sink: EventSink & AssistantTextDeltaSink;
|
|
27
40
|
}) => Promise<{
|
|
28
41
|
runtime: RuntimeSession;
|
|
29
42
|
databasePath: string;
|
|
30
43
|
modelName: string;
|
|
31
|
-
|
|
44
|
+
profileName?: string;
|
|
45
|
+
modelCatalog?: ClientModelCatalog;
|
|
46
|
+
}>) & {
|
|
47
|
+
profiles?: (workspaceId: string) => Promise<ClientModelCatalog | undefined>;
|
|
48
|
+
persistDefaultProfile?: (workspaceId: string, profileName: string) => Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type HostedResources = {
|
|
52
|
+
load(): Promise<void>;
|
|
53
|
+
unload(): void;
|
|
54
|
+
acquireTurn(signal: AbortSignal): Promise<() => void>;
|
|
55
|
+
};
|
|
32
56
|
|
|
33
57
|
export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
34
58
|
readonly name = "remote-view";
|
|
35
59
|
readonly hub: RemoteSyncHub;
|
|
36
60
|
private runtime?: RuntimeSession;
|
|
61
|
+
private lease?: SessionLease;
|
|
62
|
+
private suspending?: Promise<void>;
|
|
63
|
+
private lastAccess = Date.now();
|
|
64
|
+
private controls = 0;
|
|
65
|
+
private requests = 0;
|
|
66
|
+
private closePromise?: Promise<void>;
|
|
67
|
+
private maintenance?: Promise<unknown>;
|
|
68
|
+
private modelCatalog?: ClientModelCatalog;
|
|
69
|
+
private interactionSource?: object;
|
|
37
70
|
private reader?: RemoteHistoryReader;
|
|
71
|
+
private projection?: TuiProjectionStore;
|
|
38
72
|
private opening?: Promise<void>;
|
|
39
73
|
private readonly queue: OperationReceipt[] = [];
|
|
40
74
|
private active?: {
|
|
@@ -49,6 +83,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
49
83
|
private lastOrdinal = 0;
|
|
50
84
|
private streamTimer?: ReturnType<typeof setTimeout>;
|
|
51
85
|
private stopping = false;
|
|
86
|
+
private connections = 0;
|
|
52
87
|
private unsubscribers: (() => void)[] = [];
|
|
53
88
|
private controlTail: Promise<void> = Promise.resolve();
|
|
54
89
|
|
|
@@ -57,17 +92,80 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
57
92
|
private readonly store: RemoteServiceStore,
|
|
58
93
|
epoch: string,
|
|
59
94
|
private readonly factory: HostedRuntimeFactory,
|
|
95
|
+
private readonly resources?: HostedResources,
|
|
60
96
|
) {
|
|
61
97
|
this.hub = new RemoteSyncHub(epoch, () => this.view());
|
|
62
98
|
}
|
|
63
99
|
|
|
100
|
+
assertAvailable(): void {
|
|
101
|
+
if (this.stopping)
|
|
102
|
+
throw new RemoteError(409, "SESSION_CLOSING", "Session is closing.");
|
|
103
|
+
}
|
|
104
|
+
get connectedClients(): number {
|
|
105
|
+
return this.connections;
|
|
106
|
+
}
|
|
107
|
+
attach(request = false): () => void {
|
|
108
|
+
this.assertAvailable();
|
|
109
|
+
this.lastAccess = Date.now();
|
|
110
|
+
this.connections += 1;
|
|
111
|
+
if (request) this.requests++;
|
|
112
|
+
let released = false;
|
|
113
|
+
return () => {
|
|
114
|
+
if (!released) {
|
|
115
|
+
released = true;
|
|
116
|
+
this.connections -= 1;
|
|
117
|
+
if (request) this.requests--;
|
|
118
|
+
this.lastAccess = Date.now();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
beginDelete(): void {
|
|
123
|
+
this.assertIdle();
|
|
124
|
+
if (this.connections > 0)
|
|
125
|
+
throw new RemoteError(
|
|
126
|
+
409,
|
|
127
|
+
"SESSION_CONNECTED",
|
|
128
|
+
"Cannot delete a session while a client is connected. Disconnect it first.",
|
|
129
|
+
);
|
|
130
|
+
this.stopping = true;
|
|
131
|
+
}
|
|
64
132
|
open(): Promise<void> {
|
|
65
|
-
|
|
133
|
+
this.assertAvailable();
|
|
134
|
+
this.lastAccess = Date.now();
|
|
135
|
+
if (this.suspending) return this.suspending.then(() => this.open());
|
|
136
|
+
return (this.opening ??= this.initialize().catch((error: unknown) => {
|
|
137
|
+
this.opening = undefined;
|
|
138
|
+
throw error;
|
|
139
|
+
}));
|
|
66
140
|
}
|
|
67
141
|
private async initialize(): Promise<void> {
|
|
68
142
|
try {
|
|
69
|
-
|
|
143
|
+
await this.resources?.load();
|
|
144
|
+
const opened = await this.factory({
|
|
145
|
+
record: this.record,
|
|
146
|
+
sink: this,
|
|
147
|
+
lease: this.lease,
|
|
148
|
+
});
|
|
70
149
|
this.runtime = opened.runtime;
|
|
150
|
+
this.lease = this.runtime.retainSessionLease();
|
|
151
|
+
if (this.record.residentState?.reasoning)
|
|
152
|
+
this.runtime.setReasoningEffort(this.record.residentState.reasoning);
|
|
153
|
+
if (this.record.residentState?.yolo !== undefined)
|
|
154
|
+
this.runtime.setYoloMode(this.record.residentState.yolo);
|
|
155
|
+
this.modelCatalog = opened.modelCatalog;
|
|
156
|
+
this.projection = new TuiProjectionStore({
|
|
157
|
+
sessionId: this.record.id,
|
|
158
|
+
workspaceRoot: this.record.workspacePath,
|
|
159
|
+
modelName: opened.modelName,
|
|
160
|
+
committedItemLimit: 512,
|
|
161
|
+
initialSnapshot: ResumeProjectionReader.readDatabase({
|
|
162
|
+
databasePath: opened.databasePath,
|
|
163
|
+
workspaceRoot: this.record.workspacePath,
|
|
164
|
+
sessionId: parseSessionId(this.record.id),
|
|
165
|
+
modelName: opened.modelName,
|
|
166
|
+
closedTurnsOnly: true,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
71
169
|
this.reader = new RemoteHistoryReader(
|
|
72
170
|
opened.databasePath,
|
|
73
171
|
parseSessionId(this.record.id),
|
|
@@ -77,36 +175,14 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
77
175
|
...this.record,
|
|
78
176
|
initialized: true,
|
|
79
177
|
modelName: opened.modelName,
|
|
178
|
+
profileName: opened.profileName,
|
|
80
179
|
status: "idle",
|
|
81
180
|
};
|
|
82
181
|
this.store.saveSession(this.record);
|
|
83
|
-
|
|
84
|
-
if (latest && latest.status !== "open") {
|
|
85
|
-
this.activity.status = latest.status as RemoteActivity["status"];
|
|
86
|
-
this.activity.error = latest.error;
|
|
87
|
-
}
|
|
88
|
-
// Canonical terminal status wins over a crash between turn commit and receipt update.
|
|
89
|
-
for (const receipt of this.store.operations(this.record.id)) {
|
|
90
|
-
const status = receipt.turnId
|
|
91
|
-
? this.reader.turnStatus(receipt.turnId)
|
|
92
|
-
: undefined;
|
|
93
|
-
if (receipt.kind === "prompt" && status && status !== "open") {
|
|
94
|
-
this.store.update({
|
|
95
|
-
...receipt,
|
|
96
|
-
status: status as OperationReceipt["status"],
|
|
97
|
-
error: status === "interrupted" ? receipt.error : undefined,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
const last = this.store
|
|
102
|
-
.operations(this.record.id)
|
|
103
|
-
.filter((op) => op.kind === "prompt")
|
|
104
|
-
.at(-1);
|
|
105
|
-
if (last?.status === "interrupted") {
|
|
106
|
-
this.activity.status = "interrupted";
|
|
107
|
-
this.activity.error = last.error;
|
|
108
|
-
}
|
|
182
|
+
this.restoreActivity(this.reader);
|
|
109
183
|
this.unsubscribers = [
|
|
184
|
+
this.runtime.subscribePromptScheduler(() => this.publish()),
|
|
185
|
+
this.runtime.subscribeProviderRetry(() => this.updateInteraction()),
|
|
110
186
|
this.runtime.subscribeAskUser(() => this.updateInteraction()),
|
|
111
187
|
this.runtime.subscribeBashGuard(() => this.updateInteraction()),
|
|
112
188
|
];
|
|
@@ -118,14 +194,95 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
118
194
|
await this.runtime
|
|
119
195
|
.dispose({ type: "runner_failed", error: errorMessage(error) })
|
|
120
196
|
.catch(() => undefined);
|
|
197
|
+
this.reader?.close();
|
|
198
|
+
this.reader = undefined;
|
|
199
|
+
this.projection = undefined;
|
|
200
|
+
this.runtime = undefined;
|
|
201
|
+
this.resources?.unload();
|
|
121
202
|
this.publish();
|
|
122
203
|
throw error;
|
|
123
204
|
}
|
|
124
205
|
}
|
|
125
206
|
|
|
207
|
+
private restoreActivity(reader: RemoteHistoryReader): void {
|
|
208
|
+
this.activity = { status: "idle", tools: [] };
|
|
209
|
+
const latest = reader.latestTurn();
|
|
210
|
+
if (latest && latest.status !== "open") {
|
|
211
|
+
this.activity.status = latest.status as RemoteActivity["status"];
|
|
212
|
+
this.activity.error = latest.error;
|
|
213
|
+
}
|
|
214
|
+
// Canonical terminal status wins over a crash between turn commit and receipt update.
|
|
215
|
+
for (const receipt of this.store.operations(this.record.id)) {
|
|
216
|
+
const status = receipt.turnId ? reader.turnStatus(receipt.turnId) : undefined;
|
|
217
|
+
// A terminal turn cannot prove that an extended execution chain finished.
|
|
218
|
+
// Without its final result, a crash may have lost unconsumed follow-ups.
|
|
219
|
+
if (receipt.hasFollowUps && !receipt.result && receipt.status === "interrupted")
|
|
220
|
+
continue;
|
|
221
|
+
if (
|
|
222
|
+
receipt.kind === "prompt" &&
|
|
223
|
+
!receipt.shutdownInterrupted &&
|
|
224
|
+
status &&
|
|
225
|
+
status !== "open"
|
|
226
|
+
) {
|
|
227
|
+
this.store.update({
|
|
228
|
+
...receipt,
|
|
229
|
+
status: status as OperationReceipt["status"],
|
|
230
|
+
error: status === "interrupted" ? receipt.error : undefined,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const last = this.store
|
|
235
|
+
.operations(this.record.id)
|
|
236
|
+
.filter((op) => op.kind === "prompt")
|
|
237
|
+
.at(-1);
|
|
238
|
+
if (last?.status === "interrupted") {
|
|
239
|
+
this.activity.status = "interrupted";
|
|
240
|
+
this.activity.error = last.error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
126
244
|
view(): RemoteView {
|
|
127
245
|
return { ...this.readActivity(), history: this.history() };
|
|
128
246
|
}
|
|
247
|
+
tuiSnapshot(): RemoteTuiSnapshot {
|
|
248
|
+
if (!this.projection || !this.runtime) throw new Error("Session is not ready.");
|
|
249
|
+
const frame = this.hub.snapshot();
|
|
250
|
+
return {
|
|
251
|
+
version: 1,
|
|
252
|
+
cursor: { epoch: frame.epoch, sequence: frame.sequence },
|
|
253
|
+
activity: this.readActivity(),
|
|
254
|
+
history: this.projection.getSnapshot(),
|
|
255
|
+
timeline: this.projection.getLogSnapshot(),
|
|
256
|
+
promptScheduler: this.runtime.promptScheduler(),
|
|
257
|
+
profileName: this.record.profileName,
|
|
258
|
+
modelCatalog: this.modelCatalog,
|
|
259
|
+
reasoningEffort: this.runtime.reasoningEffort(),
|
|
260
|
+
supportsImageInput: this.runtime.supportsImageInput(),
|
|
261
|
+
bashGuard: {
|
|
262
|
+
...this.runtime.bashGuard(),
|
|
263
|
+
...(this.runtime.bashGuard().pending
|
|
264
|
+
? { interactionId: this.activity.interaction?.id }
|
|
265
|
+
: {}),
|
|
266
|
+
},
|
|
267
|
+
askUser: {
|
|
268
|
+
...this.runtime.askUser(),
|
|
269
|
+
...(this.runtime.askUser().pending
|
|
270
|
+
? { interactionId: this.activity.interaction?.id }
|
|
271
|
+
: {}),
|
|
272
|
+
},
|
|
273
|
+
providerRetry:
|
|
274
|
+
this.runtime.providerRetry().pending && this.activity.interaction
|
|
275
|
+
? {
|
|
276
|
+
pending: {
|
|
277
|
+
...this.runtime.providerRetry().pending!,
|
|
278
|
+
requestId: this.activity.interaction.id,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
: {},
|
|
282
|
+
skills: this.runtime.skills(),
|
|
283
|
+
mcp: this.runtime.mcp(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
129
286
|
history(before?: number, limit?: number): RemoteHistoryPage {
|
|
130
287
|
return this.reader?.page(before, limit) ?? { messages: [], hasMore: false };
|
|
131
288
|
}
|
|
@@ -135,11 +292,78 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
135
292
|
return structuredClone({
|
|
136
293
|
...this.activity,
|
|
137
294
|
session: { ...session, status: this.activity.status },
|
|
138
|
-
|
|
295
|
+
// Final results are fetched by operation ID, not repeated in every live frame.
|
|
296
|
+
operations: this.store.operations(this.record.id, false),
|
|
139
297
|
});
|
|
140
298
|
}
|
|
141
299
|
|
|
300
|
+
updateModelCatalog(catalog: ClientModelCatalog | undefined): void {
|
|
301
|
+
this.modelCatalog = catalog;
|
|
302
|
+
this.publish();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
assertIdle(): void {
|
|
306
|
+
if (
|
|
307
|
+
this.stopping ||
|
|
308
|
+
this.suspending ||
|
|
309
|
+
this.maintenance ||
|
|
310
|
+
this.active ||
|
|
311
|
+
this.queue.length ||
|
|
312
|
+
!this.runtime?.canSwitchSession()
|
|
313
|
+
)
|
|
314
|
+
throw new RemoteError(
|
|
315
|
+
409,
|
|
316
|
+
"SESSION_BUSY",
|
|
317
|
+
"A turn, context operation or background task is active.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
get id() {
|
|
321
|
+
return parseSessionId(this.record.id);
|
|
322
|
+
}
|
|
323
|
+
get hasTurns(): boolean {
|
|
324
|
+
return this.reader?.latestTurn() !== undefined;
|
|
325
|
+
}
|
|
326
|
+
exclusive<T>(operation: (runtime: RuntimeSession) => Promise<T>): Promise<T> {
|
|
327
|
+
this.assertIdle();
|
|
328
|
+
const pending = Promise.resolve()
|
|
329
|
+
.then(() => operation(this.runtime!))
|
|
330
|
+
.finally(() => {
|
|
331
|
+
if (this.maintenance === pending) this.maintenance = undefined;
|
|
332
|
+
this.publish();
|
|
333
|
+
});
|
|
334
|
+
this.maintenance = pending;
|
|
335
|
+
return pending;
|
|
336
|
+
}
|
|
337
|
+
runMaintenance(
|
|
338
|
+
receipt: OperationReceipt,
|
|
339
|
+
operation: (runtime: RuntimeSession) => Promise<SessionOperationResult>,
|
|
340
|
+
): void {
|
|
341
|
+
const running = this.updateReceipt(receipt, { status: "running" });
|
|
342
|
+
void this.exclusive(operation).then(
|
|
343
|
+
(sessionResult) => {
|
|
344
|
+
this.saveResidentState();
|
|
345
|
+
this.updateReceipt(running, { status: "completed", sessionResult });
|
|
346
|
+
this.publish();
|
|
347
|
+
},
|
|
348
|
+
(error: unknown) => {
|
|
349
|
+
this.updateReceipt(running, {
|
|
350
|
+
status: "failed",
|
|
351
|
+
error: errorMessage(error),
|
|
352
|
+
failure: encodeFailure(error),
|
|
353
|
+
});
|
|
354
|
+
this.publish();
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
142
359
|
validate(input: RemoteOperationInput): void {
|
|
360
|
+
if (isSessionOperation(input)) this.assertIdle();
|
|
361
|
+
if (input.kind === "prompt" && this.maintenance)
|
|
362
|
+
throw new RemoteError(
|
|
363
|
+
409,
|
|
364
|
+
"SESSION_BUSY",
|
|
365
|
+
"A session maintenance operation is active.",
|
|
366
|
+
);
|
|
143
367
|
if (this.stopping)
|
|
144
368
|
throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
|
|
145
369
|
if (input.kind === "prompt" && this.queue.length >= 8)
|
|
@@ -148,12 +372,17 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
148
372
|
"QUEUE_FULL",
|
|
149
373
|
"This session already has eight queued requests.",
|
|
150
374
|
);
|
|
151
|
-
if (
|
|
375
|
+
if (
|
|
376
|
+
input.kind === "answer" ||
|
|
377
|
+
input.kind === "confirm" ||
|
|
378
|
+
input.kind === "provider_retry"
|
|
379
|
+
) {
|
|
152
380
|
const pending = this.activity.interaction;
|
|
153
381
|
if (
|
|
154
382
|
!pending ||
|
|
155
383
|
pending.id !== input.interactionId ||
|
|
156
|
-
pending.kind !== (input.kind === "
|
|
384
|
+
pending.kind !== (input.kind === "confirm" ? "confirmation" : "question") ||
|
|
385
|
+
(input.kind === "provider_retry" && !this.runtime?.providerRetry().pending)
|
|
157
386
|
)
|
|
158
387
|
throw new RemoteError(
|
|
159
388
|
409,
|
|
@@ -168,6 +397,16 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
168
397
|
)
|
|
169
398
|
throw new RemoteError(400, "INVALID_ANSWER", "Answer index is out of range.");
|
|
170
399
|
}
|
|
400
|
+
if (
|
|
401
|
+
input.kind === "follow_up" &&
|
|
402
|
+
(this.active?.receipt.requestId !== input.targetRequestId ||
|
|
403
|
+
this.runtime?.promptScheduler().state !== "running")
|
|
404
|
+
)
|
|
405
|
+
throw new RemoteError(
|
|
406
|
+
409,
|
|
407
|
+
"STALE_TURN",
|
|
408
|
+
"The targeted execution is no longer running.",
|
|
409
|
+
);
|
|
171
410
|
if (input.kind === "stop") {
|
|
172
411
|
const target = this.store.get(input.targetRequestId);
|
|
173
412
|
if (target.sessionId !== this.record.id || target.kind !== "prompt")
|
|
@@ -205,7 +444,9 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
205
444
|
active.completion = this.execute(active);
|
|
206
445
|
}
|
|
207
446
|
private async execute(active: NonNullable<HostedSession["active"]>): Promise<void> {
|
|
447
|
+
let release: (() => void) | undefined;
|
|
208
448
|
try {
|
|
449
|
+
release = await this.resources?.acquireTurn(active.controller.signal);
|
|
209
450
|
await this.open();
|
|
210
451
|
if (active.controller.signal.aborted) {
|
|
211
452
|
this.updateReceipt(active.receipt, { status: "cancelled" });
|
|
@@ -219,7 +460,13 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
219
460
|
this.updateReceipt(active.receipt, { status: "running" });
|
|
220
461
|
this.publish();
|
|
221
462
|
const accepted = await this.runtime!.admitTurn({
|
|
222
|
-
userMessage: {
|
|
463
|
+
userMessage: {
|
|
464
|
+
role: "user",
|
|
465
|
+
content: active.receipt.prompt!,
|
|
466
|
+
...(active.receipt.attachments
|
|
467
|
+
? { attachments: active.receipt.attachments }
|
|
468
|
+
: {}),
|
|
469
|
+
},
|
|
223
470
|
signal: active.controller.signal,
|
|
224
471
|
});
|
|
225
472
|
active.receipt = this.updateReceipt(active.receipt, {
|
|
@@ -231,6 +478,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
231
478
|
const result = await accepted.completion;
|
|
232
479
|
this.updateReceipt(active.receipt, {
|
|
233
480
|
status: result.status,
|
|
481
|
+
result,
|
|
234
482
|
...(result.status === "failed" ? { error: result.error } : {}),
|
|
235
483
|
});
|
|
236
484
|
this.activity.status = result.status;
|
|
@@ -240,6 +488,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
240
488
|
this.updateReceipt(active.receipt, {
|
|
241
489
|
status,
|
|
242
490
|
error: errorMessage(error),
|
|
491
|
+
failure: encodeFailure(error),
|
|
243
492
|
});
|
|
244
493
|
this.activity.status = status;
|
|
245
494
|
this.activity.error = errorMessage(error);
|
|
@@ -253,6 +502,8 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
253
502
|
tool.status = this.activity.status === "cancelled" ? "cancelled" : "failed";
|
|
254
503
|
}
|
|
255
504
|
this.active = undefined;
|
|
505
|
+
this.lastAccess = Date.now();
|
|
506
|
+
release?.();
|
|
256
507
|
this.publish(true);
|
|
257
508
|
this.pump();
|
|
258
509
|
}
|
|
@@ -260,38 +511,66 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
260
511
|
|
|
261
512
|
control(input: RemoteOperationInput, receipt: OperationReceipt): void {
|
|
262
513
|
// Separate from prompt execution: answering a wait cannot wait for that turn.
|
|
263
|
-
this.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
514
|
+
this.controls++;
|
|
515
|
+
this.controlTail = this.controlTail
|
|
516
|
+
.then(async () => {
|
|
517
|
+
try {
|
|
518
|
+
await this.open();
|
|
519
|
+
this.validate(input);
|
|
520
|
+
if (input.kind === "stop") {
|
|
521
|
+
if (this.active?.receipt.requestId === input.targetRequestId)
|
|
522
|
+
this.active.controller.abort();
|
|
523
|
+
const index = this.queue.findIndex(
|
|
524
|
+
(op) => op.requestId === input.targetRequestId,
|
|
525
|
+
);
|
|
526
|
+
if (index !== -1)
|
|
527
|
+
this.updateReceipt(this.queue.splice(index, 1)[0], {
|
|
528
|
+
status: "cancelled",
|
|
529
|
+
});
|
|
530
|
+
} else if (input.kind === "follow_up") {
|
|
531
|
+
const followUp = this.runtime!.queueFollowUp({
|
|
532
|
+
role: "user",
|
|
533
|
+
content: input.prompt,
|
|
276
534
|
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
535
|
+
this.updateReceipt(this.active!.receipt, { hasFollowUps: true });
|
|
536
|
+
this.updateReceipt(receipt, { status: "completed", followUp });
|
|
537
|
+
this.publish();
|
|
538
|
+
return;
|
|
539
|
+
} else if (
|
|
540
|
+
input.kind === "provider_retry" ||
|
|
541
|
+
(input.kind === "answer" && this.runtime!.providerRetry().pending)
|
|
542
|
+
) {
|
|
543
|
+
const retry = this.runtime!.providerRetry().pending!;
|
|
544
|
+
await this.runtime!.resolveProviderRetry(
|
|
545
|
+
retry.requestId,
|
|
546
|
+
input.kind === "provider_retry"
|
|
547
|
+
? input.decision
|
|
548
|
+
: input.selectedIndex === 0
|
|
549
|
+
? "retry"
|
|
550
|
+
: "stop",
|
|
551
|
+
);
|
|
552
|
+
} else if (input.kind === "answer") {
|
|
553
|
+
await this.runtime!.resolveAskUser(
|
|
554
|
+
input.selectedIndex === null
|
|
555
|
+
? { outcome: "dismissed" }
|
|
556
|
+
: { outcome: "selected", selectedIndex: input.selectedIndex },
|
|
557
|
+
);
|
|
558
|
+
} else if (input.kind === "confirm") {
|
|
559
|
+
await this.runtime!.resolveBashConfirmation(input.decision);
|
|
560
|
+
}
|
|
561
|
+
this.updateReceipt(receipt, { status: "completed" });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
this.updateReceipt(receipt, {
|
|
564
|
+
status: "failed",
|
|
565
|
+
error: errorMessage(error),
|
|
566
|
+
});
|
|
285
567
|
}
|
|
286
|
-
this.
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
this.publish();
|
|
294
|
-
});
|
|
568
|
+
this.publish();
|
|
569
|
+
})
|
|
570
|
+
.finally(() => {
|
|
571
|
+
this.controls--;
|
|
572
|
+
this.lastAccess = Date.now();
|
|
573
|
+
});
|
|
295
574
|
}
|
|
296
575
|
|
|
297
576
|
private updateReceipt(
|
|
@@ -312,14 +591,28 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
312
591
|
private updateInteraction(): void {
|
|
313
592
|
const question = this.runtime?.askUser().pending;
|
|
314
593
|
const confirmation = this.runtime?.bashGuard().pending;
|
|
315
|
-
|
|
316
|
-
|
|
594
|
+
const retry = this.runtime?.providerRetry().pending;
|
|
595
|
+
const source = question ?? confirmation ?? retry;
|
|
596
|
+
if (source) {
|
|
597
|
+
if (source !== this.interactionSource) {
|
|
598
|
+
this.interactionSource = source;
|
|
317
599
|
this.activity.interaction = question
|
|
318
600
|
? { id: randomUUID(), kind: "question", ...question }
|
|
319
|
-
:
|
|
601
|
+
: confirmation
|
|
602
|
+
? { id: randomUUID(), kind: "confirmation", ...confirmation }
|
|
603
|
+
: {
|
|
604
|
+
id: randomUUID(),
|
|
605
|
+
kind: "question",
|
|
606
|
+
question: `Provider request failed. Automatic retries exhausted. ${retry!.failure.error.slice(0, 500)}`,
|
|
607
|
+
options: [
|
|
608
|
+
{ description: "Retry again" },
|
|
609
|
+
{ description: "End this turn" },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
320
612
|
}
|
|
321
613
|
this.activity.status = "waiting_input";
|
|
322
614
|
} else {
|
|
615
|
+
this.interactionSource = undefined;
|
|
323
616
|
this.activity.interaction = undefined;
|
|
324
617
|
if (this.active) this.activity.status = "running";
|
|
325
618
|
}
|
|
@@ -332,6 +625,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
332
625
|
|
|
333
626
|
async append(event: AgentEvent): Promise<void> {
|
|
334
627
|
if (!this.reader) return;
|
|
628
|
+
await this.projection?.append(event);
|
|
335
629
|
switch (event.type) {
|
|
336
630
|
case "turn.started":
|
|
337
631
|
this.activity.activeTurnId = event.turnId;
|
|
@@ -370,11 +664,12 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
370
664
|
case "turn.steering.applied":
|
|
371
665
|
break;
|
|
372
666
|
default:
|
|
373
|
-
|
|
667
|
+
break;
|
|
374
668
|
}
|
|
375
669
|
this.publish();
|
|
376
670
|
}
|
|
377
671
|
updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
|
|
672
|
+
this.projection?.updateAssistantTextDelta(update);
|
|
378
673
|
const previous = this.activity.streaming;
|
|
379
674
|
this.activity.streaming = {
|
|
380
675
|
iterationId: update.iterationId,
|
|
@@ -407,25 +702,154 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
|
|
|
407
702
|
this.hub.publish({ activity: this.readActivity(), messages });
|
|
408
703
|
}
|
|
409
704
|
|
|
410
|
-
|
|
705
|
+
get lastUsed(): number {
|
|
706
|
+
return this.lastAccess;
|
|
707
|
+
}
|
|
708
|
+
get reclaimable(): boolean {
|
|
709
|
+
return (
|
|
710
|
+
!!this.runtime &&
|
|
711
|
+
!this.stopping &&
|
|
712
|
+
!this.suspending &&
|
|
713
|
+
!this.connections &&
|
|
714
|
+
!this.active &&
|
|
715
|
+
!this.queue.length &&
|
|
716
|
+
!this.maintenance &&
|
|
717
|
+
!this.controls &&
|
|
718
|
+
this.runtime.canSwitchSession()
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
get busy(): boolean {
|
|
722
|
+
return (
|
|
723
|
+
!!this.active ||
|
|
724
|
+
!!this.queue.length ||
|
|
725
|
+
!!this.maintenance ||
|
|
726
|
+
!!this.controls ||
|
|
727
|
+
!!this.requests ||
|
|
728
|
+
(!!this.opening && !this.runtime) ||
|
|
729
|
+
(!!this.runtime && !this.runtime.canSwitchSession())
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
async prepareDormant(homeRoot?: string): Promise<void> {
|
|
733
|
+
try {
|
|
734
|
+
await this.recoverDormant(homeRoot);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
this.activity = { status: "failed", tools: [], error: errorMessage(error) };
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
private async recoverDormant(homeRoot?: string): Promise<void> {
|
|
741
|
+
if (!this.record.initialized) return;
|
|
742
|
+
const store = await SessionStore.openExisting({
|
|
743
|
+
workspaceRoot: this.record.workspacePath,
|
|
744
|
+
homeRoot,
|
|
745
|
+
sessionId: this.id,
|
|
746
|
+
});
|
|
747
|
+
this.lease = store.retainLease();
|
|
748
|
+
try {
|
|
749
|
+
store.recoverInterruptedState(runtimeIdFactory);
|
|
750
|
+
} finally {
|
|
751
|
+
await store.close("session_switch");
|
|
752
|
+
}
|
|
753
|
+
const reader = new RemoteHistoryReader(
|
|
754
|
+
store.databasePath,
|
|
755
|
+
this.id,
|
|
756
|
+
this.record.workspacePath,
|
|
757
|
+
);
|
|
758
|
+
try {
|
|
759
|
+
this.restoreActivity(reader);
|
|
760
|
+
} finally {
|
|
761
|
+
reader.close();
|
|
762
|
+
}
|
|
763
|
+
this.record = { ...this.record, status: this.activity.status };
|
|
764
|
+
this.store.saveSession(this.record);
|
|
765
|
+
}
|
|
766
|
+
suspend(): Promise<void> {
|
|
767
|
+
if (this.suspending) return this.suspending;
|
|
768
|
+
if (!this.reclaimable) return Promise.resolve();
|
|
769
|
+
this.suspending = this.unload().finally(() => {
|
|
770
|
+
this.suspending = undefined;
|
|
771
|
+
});
|
|
772
|
+
return this.suspending;
|
|
773
|
+
}
|
|
774
|
+
private saveResidentState(): void {
|
|
775
|
+
if (!this.runtime) return;
|
|
776
|
+
const reasoning = this.runtime.reasoningEffort();
|
|
777
|
+
const guard = this.runtime.bashGuard();
|
|
778
|
+
this.record = {
|
|
779
|
+
...this.record,
|
|
780
|
+
status: this.activity.status,
|
|
781
|
+
residentState: {
|
|
782
|
+
...(reasoning?.source === "session_override"
|
|
783
|
+
? { reasoning: reasoning.effort }
|
|
784
|
+
: {}),
|
|
785
|
+
...(guard.source === "session" ? { yolo: guard.mode === "yolo" } : {}),
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
this.store.saveSession(this.record);
|
|
789
|
+
}
|
|
790
|
+
private async unload(): Promise<void> {
|
|
791
|
+
const runtime = this.runtime!;
|
|
792
|
+
this.saveResidentState();
|
|
793
|
+
try {
|
|
794
|
+
await runtime.dispose({ type: "session_switch" });
|
|
795
|
+
} finally {
|
|
796
|
+
for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
|
|
797
|
+
this.reader?.close();
|
|
798
|
+
this.reader = undefined;
|
|
799
|
+
this.projection = undefined;
|
|
800
|
+
this.modelCatalog = undefined;
|
|
801
|
+
this.interactionSource = undefined;
|
|
802
|
+
this.activity = {
|
|
803
|
+
status: this.activity.status,
|
|
804
|
+
error: this.activity.error,
|
|
805
|
+
tools: [],
|
|
806
|
+
};
|
|
807
|
+
this.runtime = undefined;
|
|
808
|
+
this.opening = undefined;
|
|
809
|
+
this.hub.close();
|
|
810
|
+
this.resources?.unload();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
close(): Promise<void> {
|
|
814
|
+
return (this.closePromise ??= this.performClose());
|
|
815
|
+
}
|
|
816
|
+
private async performClose(): Promise<void> {
|
|
411
817
|
this.stopping = true;
|
|
818
|
+
this.saveResidentState();
|
|
819
|
+
const interrupted = this.active?.receipt;
|
|
820
|
+
this.active?.controller.abort();
|
|
412
821
|
for (const receipt of this.queue.splice(0))
|
|
413
822
|
this.updateReceipt(receipt, {
|
|
414
823
|
status: "interrupted",
|
|
415
|
-
error: "The service
|
|
824
|
+
error: "The service stopped before this queued request began.",
|
|
416
825
|
});
|
|
826
|
+
await this.suspending?.catch(() => undefined);
|
|
417
827
|
if (this.opening) await this.opening.catch(() => undefined);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
828
|
+
try {
|
|
829
|
+
if (this.runtime)
|
|
830
|
+
await this.runtime.dispose({
|
|
831
|
+
type: "runner_failed",
|
|
832
|
+
error: "Remote service shutdown.",
|
|
833
|
+
});
|
|
834
|
+
await this.maintenance?.catch(() => undefined);
|
|
835
|
+
await this.active?.completion;
|
|
836
|
+
if (interrupted) {
|
|
837
|
+
this.updateReceipt(interrupted, {
|
|
838
|
+
status: "interrupted",
|
|
839
|
+
shutdownInterrupted: true,
|
|
840
|
+
error: "Service shutdown interrupted this request; it will not be replayed.",
|
|
841
|
+
});
|
|
842
|
+
this.activity.status = "interrupted";
|
|
843
|
+
}
|
|
844
|
+
await this.controlTail;
|
|
845
|
+
} finally {
|
|
846
|
+
for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
|
|
847
|
+
if (this.streamTimer) clearTimeout(this.streamTimer);
|
|
848
|
+
this.hub.close();
|
|
849
|
+
this.reader?.close();
|
|
850
|
+
this.resources?.unload();
|
|
851
|
+
await this.lease?.releaseOwnership();
|
|
852
|
+
}
|
|
429
853
|
}
|
|
430
854
|
get pendingCount(): number {
|
|
431
855
|
return this.queue.length + (this.active ? 1 : 0);
|