@fastagent-sh/fastagent 0.18.0 → 0.20.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/README.md +25 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +23 -0
- package/dist/atomic-write.js +53 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +55 -0
- package/dist/channels/agentcore-service.js +107 -0
- package/dist/channels/agentcore.d.ts +22 -20
- package/dist/channels/agentcore.js +20 -11
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +55 -7
- package/dist/channels/control.js +312 -67
- package/dist/channels/discover.d.ts +59 -0
- package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.js +6 -9
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +41 -24
- package/dist/channels/feishu/invoke-turn.d.ts +1 -1
- package/dist/channels/feishu/invoke-turn.js +3 -4
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +1 -109
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/{state.js → kit/state.js} +4 -7
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
- package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
- package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
- package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +2 -2
- package/dist/channels/slack/invoke-turn.js +13 -6
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +23 -55
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +20 -20
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +67 -13
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +3 -3
- package/dist/channels/telegram/telegram.js +60 -72
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +25 -40
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -5
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +80 -121
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +22 -70
- package/dist/cli/serve.js +66 -257
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +53 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +11 -6
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +295 -0
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +5 -5
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +67 -42
- package/dist/engines/pi/create.js +142 -72
- package/dist/engines/pi/definition.d.ts +42 -8
- package/dist/engines/pi/definition.js +114 -11
- package/dist/engines/pi/invoke-session.d.ts +22 -19
- package/dist/engines/pi/invoke-session.js +286 -100
- package/dist/engines/pi/login.js +39 -20
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +10 -8
- package/dist/engines/pi/open.js +21 -11
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +27 -0
- package/dist/engines/pi/session-builder.js +87 -118
- package/dist/engines/pi/session-control.d.ts +28 -14
- package/dist/engines/pi/session-control.js +570 -438
- package/dist/engines/pi/session-inheritance.d.ts +30 -0
- package/dist/engines/pi/session-inheritance.js +261 -0
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +15 -3
- package/dist/engines/pi/session-settings.js +22 -1
- package/dist/engines/pi/session-store.d.ts +140 -0
- package/dist/engines/pi/session-store.js +666 -0
- package/dist/engines/pi/tool-context.d.ts +36 -13
- package/dist/engines/pi/tool-context.js +51 -4
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +3 -3
- package/dist/engines/pi/turn-kit.d.ts +32 -6
- package/dist/engines/pi/turn-kit.js +28 -1
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/env.js +1 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +37 -2
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +2 -2
- package/dist/schedule/wakeups.js +11 -8
- package/dist/service.d.ts +157 -0
- package/dist/service.js +376 -0
- package/dist/session-remote.d.ts +8 -3
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +229 -93
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +14 -5
- package/dist/engines/pi/channel.d.ts +0 -37
- package/dist/engines/pi/harness.d.ts +0 -115
- package/dist/engines/pi/harness.js +0 -154
- package/dist/engines/pi/invoke.d.ts +0 -60
- package/dist/engines/pi/invoke.js +0 -459
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -60
- package/dist/engines/pi/sessions.js +0 -464
- package/dist/host/node.js +0 -52
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pi implementation of the session control plane
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* {@link SessionObserver} to plug into the invoke pipeline (`createPiAgentFromHarness({ observer })`)
|
|
6
|
-
* — the hub derives everything from the rich event stream (plus the {@link RunControls} the
|
|
7
|
-
* run_started event carries), holds no durable state of its own, and never writes: durable truth
|
|
8
|
-
* stays in the session repository (read via {@link PiSessionReader}), live truth in the events the
|
|
9
|
-
* data plane emits, modulation in the controls the data plane registers.
|
|
2
|
+
* The pi implementation of the session control plane. `createPiSessionControl` returns the neutral
|
|
3
|
+
* `SessionControl` plus the {@link SessionObserver} to plug into the invoke pipeline
|
|
4
|
+
* (`createPiAgent({ observer })`).
|
|
10
5
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* It holds no durable state of its own: live truth comes from the event stream (plus the
|
|
7
|
+
* {@link RunControls} a `run_started` carries), durable truth from {@link PiSessionRecordStore} —
|
|
8
|
+
* which is also what performs every write, so how a record takes a property is not knowledge this
|
|
9
|
+
* file has. What it owns is the vocabulary: capability gating, the lease, error codes, and the
|
|
10
|
+
* events its own writes emit.
|
|
11
|
+
*
|
|
12
|
+
* Writes take the same lease as runs. Without boundary wiring they reject before acceptance with
|
|
13
|
+
* `unsupported_capability` — a client gating on `capabilities()` never sends them.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { prepareCompaction } from "@earendil-works/pi-agent-core";
|
|
16
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
16
17
|
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
17
|
-
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
|
+
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
19
|
import { listModels } from "./config.js";
|
|
19
|
-
import {
|
|
20
|
-
import { THINKING_LEVELS, resolveSessionSettings } from "./session-settings.js";
|
|
20
|
+
import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
|
|
21
|
+
import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
|
|
21
22
|
import { log } from "../../log.js";
|
|
22
|
-
import { activePathEntries } from "./sessions.js";
|
|
23
23
|
// ── Entry normalization (durable plane) ──────────────────────────────────────
|
|
24
24
|
/** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
|
|
25
25
|
* AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
|
|
@@ -34,15 +34,15 @@ function textOf(content) {
|
|
|
34
34
|
.join("");
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* pi `
|
|
37
|
+
* pi `PiSessionEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
|
|
38
38
|
* kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
|
|
39
39
|
* keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
|
|
40
40
|
* cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
|
|
41
41
|
*/
|
|
42
|
-
function toSessionEntry(entry) {
|
|
42
|
+
function toSessionEntry(entry, parentId) {
|
|
43
43
|
const base = {
|
|
44
44
|
id: entry.id,
|
|
45
|
-
parentId
|
|
45
|
+
parentId,
|
|
46
46
|
timestamp: Date.parse(entry.timestamp),
|
|
47
47
|
};
|
|
48
48
|
if (entry.type === "message") {
|
|
@@ -75,16 +75,6 @@ function toSessionEntry(entry) {
|
|
|
75
75
|
}
|
|
76
76
|
return { ...base, kind: entry.type, data: {} };
|
|
77
77
|
}
|
|
78
|
-
/**
|
|
79
|
-
* THE invariant the client's rule rests on: everything `entries()` publishes is a legal `navigate`
|
|
80
|
-
* target. pi's `leaf` records are the exception — they journal a MOVE rather than mark a position
|
|
81
|
-
* (their parentId is the OLD leaf, nothing is ever chained onto them), so navigating to one would
|
|
82
|
-
* put the branch head off every conversation path. Withheld from the published plane and refused as
|
|
83
|
-
* a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
|
|
84
|
-
*/
|
|
85
|
-
function isNavigable(entry) {
|
|
86
|
-
return entry.type !== "leaf";
|
|
87
|
-
}
|
|
88
78
|
// ── Live fan-out (events plane) ──────────────────────────────────────────────
|
|
89
79
|
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
90
80
|
* connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
|
|
@@ -199,7 +189,8 @@ export function createPiSessionControl(options) {
|
|
|
199
189
|
}
|
|
200
190
|
fanOut(session, event);
|
|
201
191
|
};
|
|
202
|
-
|
|
192
|
+
// The reads, plus the two sessionless declarations. Bound onto a handle below.
|
|
193
|
+
const reads = {
|
|
203
194
|
async commands() {
|
|
204
195
|
return (await options.commands?.()) ?? [];
|
|
205
196
|
},
|
|
@@ -208,14 +199,19 @@ export function createPiSessionControl(options) {
|
|
|
208
199
|
return {
|
|
209
200
|
steering: true,
|
|
210
201
|
followUp: true,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
202
|
+
compaction: !!b,
|
|
203
|
+
// Every write — a property, a copied record, a removed one — needs the boundary wiring for
|
|
204
|
+
// its LEASE, so they answer the same question: a write that races a run would hang the next
|
|
205
|
+
// turn off a stale branch, or pull the record out from under it.
|
|
206
|
+
fork: !!b,
|
|
207
|
+
delete: !!b,
|
|
208
|
+
// The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
|
|
209
|
+
// without anyone remembering to, and one removed cannot linger here.
|
|
210
|
+
updatable: b ? [...UPDATE_FIELDS] : [],
|
|
211
|
+
// The registry is a deployment fact (any session may be pointed at any of it). Thinking
|
|
212
|
+
// LEVELS are a property of the model a session is running, so they ride
|
|
214
213
|
// `state().availableThinkingLevels` — a list here could only answer for one model.
|
|
215
|
-
|
|
216
|
-
// Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
|
|
217
|
-
// and a write that races a run would hang the next turn off a stale branch.
|
|
218
|
-
navigate: !!b,
|
|
214
|
+
...(b ? { allowedModels: listModels(b.models) } : {}),
|
|
219
215
|
toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
|
|
220
216
|
usage: false,
|
|
221
217
|
};
|
|
@@ -223,26 +219,28 @@ export function createPiSessionControl(options) {
|
|
|
223
219
|
async state(session) {
|
|
224
220
|
const run = active.get(session);
|
|
225
221
|
const opened = await sessions.openIfExists(session);
|
|
226
|
-
const leafEntryId = opened ? (
|
|
222
|
+
const leafEntryId = opened ? publishedLeaf(opened) : undefined;
|
|
227
223
|
// What will RUN, not the raw record: a client steering a session needs the pair that executes.
|
|
228
224
|
// Without a boundary there is no model to resolve against, and the fields are absent.
|
|
229
225
|
// OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
|
|
230
226
|
// control-less deployment answers with) rather than rejecting a read that has no error-code
|
|
231
227
|
// channel to explain itself. The fault is not swallowed — it surfaces where codes exist: the
|
|
232
|
-
// next invoke fails (
|
|
228
|
+
// next invoke fails (binding a session walks the same chain) and a boundary dispatch answers
|
|
233
229
|
// `boundary_command_failed`. Here it is a server-side warn.
|
|
234
230
|
const b = boundary?.();
|
|
235
231
|
let settings;
|
|
236
232
|
if (opened && b) {
|
|
237
233
|
try {
|
|
238
|
-
settings = resolveSessionSettings((
|
|
234
|
+
settings = resolveSessionSettings(activePath(opened), b.models, b.defaults);
|
|
239
235
|
}
|
|
240
236
|
catch (error) {
|
|
241
237
|
log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
|
|
242
238
|
}
|
|
243
239
|
}
|
|
240
|
+
const name = opened?.getSessionName();
|
|
244
241
|
return {
|
|
245
242
|
status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
|
|
243
|
+
...(name ? { name } : {}),
|
|
246
244
|
...(run ? { activeRunId: run.runId } : {}),
|
|
247
245
|
...(settings
|
|
248
246
|
? {
|
|
@@ -263,8 +261,28 @@ export function createPiSessionControl(options) {
|
|
|
263
261
|
// would race any concurrent append into a leaf the snapshot cannot contain — a live turn
|
|
264
262
|
// reading as a dangling head. This order makes the journal a superset of the leaf's chain,
|
|
265
263
|
// which is what lets the published head be trusted as one of the published entries.
|
|
266
|
-
const leafEntryId = (
|
|
267
|
-
const
|
|
264
|
+
const leafEntryId = publishedLeaf(opened);
|
|
265
|
+
const journal = opened.getEntries();
|
|
266
|
+
// The published tree must be SELF-CONTAINED: a `parentId` pointing at an entry this plane does
|
|
267
|
+
// not publish (a label, one of our markers) would break the walk a client does from
|
|
268
|
+
// `leafEntryId` upward — it would stop at an id it cannot look up and report a short path.
|
|
269
|
+
// So a skipped entry is spliced out: its children point at the nearest published ancestor.
|
|
270
|
+
const byId = new Map(journal.map((e) => [e.id, e]));
|
|
271
|
+
const publishedParent = (entry) => {
|
|
272
|
+
let parent = entry.parentId ?? undefined;
|
|
273
|
+
while (parent) {
|
|
274
|
+
const found = byId.get(parent);
|
|
275
|
+
// A gap in the chain is left as a gap — `state()` reports it and the next invoke fails on
|
|
276
|
+
// it (design §7); inventing a parent here would hide a corrupt journal.
|
|
277
|
+
if (!found)
|
|
278
|
+
return parent;
|
|
279
|
+
if (isNavigable(found))
|
|
280
|
+
return parent;
|
|
281
|
+
parent = found.parentId ?? undefined;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
};
|
|
285
|
+
const all = journal.filter(isNavigable).map((e) => toSessionEntry(e, publishedParent(e)));
|
|
268
286
|
let entries = all;
|
|
269
287
|
if (opts?.since !== undefined) {
|
|
270
288
|
const idx = all.findIndex((e) => e.id === opts.since);
|
|
@@ -331,405 +349,519 @@ export function createPiSessionControl(options) {
|
|
|
331
349
|
},
|
|
332
350
|
};
|
|
333
351
|
},
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
352
|
+
};
|
|
353
|
+
/** No boundary wiring — the write path does not exist in this deployment. A capability-gating
|
|
354
|
+
* client never lands here; one that does gets the same answer every gate publishes. */
|
|
355
|
+
const unsupported = (what) => ({
|
|
356
|
+
ok: false,
|
|
357
|
+
error: {
|
|
358
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
359
|
+
message: `${what} is not supported by this runtime (no boundary wiring)`,
|
|
360
|
+
retryable: false,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const noSuchSession = (session) => ({
|
|
364
|
+
ok: false,
|
|
365
|
+
error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
|
|
366
|
+
});
|
|
367
|
+
const invalid = (message) => ({
|
|
368
|
+
ok: false,
|
|
369
|
+
error: { code: INVALID_COMMAND_CODE, message, retryable: false },
|
|
370
|
+
});
|
|
371
|
+
const busy = () => ({
|
|
372
|
+
ok: false,
|
|
373
|
+
error: {
|
|
374
|
+
code: SESSION_BUSY_CODE,
|
|
375
|
+
message: "session busy: a run (or another write) is in flight — retry at idle",
|
|
376
|
+
retryable: true,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const failed = (error) => ({
|
|
380
|
+
ok: false,
|
|
381
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
382
|
+
});
|
|
383
|
+
/** steer / follow_up / abort — the run actions. They reach the LIVE run through the controls
|
|
384
|
+
* registered with `run_started`; nothing durable is written. */
|
|
385
|
+
const runAction = async (session, action) => {
|
|
386
|
+
const run = active.get(session);
|
|
387
|
+
if (!run) {
|
|
388
|
+
// Run/compaction symmetry: an in-flight compaction is a model call too, and `abort` is its
|
|
389
|
+
// only door — interrupting it converges through the detached task's catch into
|
|
390
|
+
// `compaction_finished{aborted}` with the lease released; answering no_active_run against a
|
|
391
|
+
// state() that says "compacting" would be a lie.
|
|
392
|
+
const comp = action.type === "abort" ? compacting.get(session) : undefined;
|
|
393
|
+
if (comp) {
|
|
394
|
+
comp.abort();
|
|
395
|
+
return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
|
|
396
|
+
}
|
|
397
|
+
// Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false — the same
|
|
398
|
+
// call fails again; call it after state() shows an active run.
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
error: {
|
|
402
|
+
code: NO_ACTIVE_RUN_CODE,
|
|
403
|
+
message: `no active run for this session — ${action.type} modulates a run an invoke is driving`,
|
|
404
|
+
retryable: false,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (!run.controls) {
|
|
409
|
+
// A run EXISTS (state() rightly reports running) but was registered observation-only (the
|
|
410
|
+
// observer seam allows run_started without controls). That is a CAPABILITY problem, not a run
|
|
411
|
+
// problem — permanent for this wiring, so neither no_active_run (would poll forever) nor
|
|
412
|
+
// run_command_failed (transient) fits.
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
error: {
|
|
416
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
417
|
+
message: `the active run registered without modulation controls (observation-only) — ${action.type} cannot reach it`,
|
|
418
|
+
retryable: false,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
if (action.type === "steer")
|
|
424
|
+
await run.controls.steer(action.prompt);
|
|
425
|
+
else if (action.type === "follow_up")
|
|
426
|
+
await run.controls.followUp(action.prompt);
|
|
427
|
+
else
|
|
428
|
+
await run.controls.abort();
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
// The run raced us to settlement, failed setup, or the engine refused: still pre-acceptance
|
|
432
|
+
// (nothing was queued), distinct from "no run existed". retryable: false for the same reason —
|
|
433
|
+
// the run is gone; consult state() before calling again.
|
|
434
|
+
return { ok: false, error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false } };
|
|
435
|
+
}
|
|
436
|
+
// Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
|
|
437
|
+
return { ok: true, runId: run.runId };
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* {@link Session.update} — validate the whole patch, take the lease once, hand the writes to the
|
|
441
|
+
* store, report what landed.
|
|
442
|
+
*
|
|
443
|
+
* Everything before the lease is validation, which is what makes a rejected patch leave nothing
|
|
444
|
+
* behind. What a value MEANS is decided here (a model spec against the registry, a level against
|
|
445
|
+
* the model it lands on); how a record takes it is the store's.
|
|
446
|
+
*/
|
|
447
|
+
const updateOf = async (session, patch) => {
|
|
448
|
+
// Keys, not values: a field this runtime does not know must not be silently skipped — that is a
|
|
449
|
+
// client typo, or a newer client talking to an older serve, and both need to hear about it.
|
|
450
|
+
const named = Object.keys(patch);
|
|
451
|
+
const unknown = named.filter((f) => !UPDATE_FIELDS.includes(f));
|
|
452
|
+
if (unknown.length > 0) {
|
|
453
|
+
return unsupported(`update field(s) ${unknown.join(", ")} — capabilities().updatable lists what this serve sets`);
|
|
454
|
+
}
|
|
455
|
+
const fields = named.filter((f) => patch[f] !== undefined);
|
|
456
|
+
if (fields.length === 0)
|
|
457
|
+
return { ok: true }; // an empty patch asks for nothing, and gets it
|
|
458
|
+
const b = boundary?.();
|
|
459
|
+
if (!b)
|
|
460
|
+
return unsupported(`update(${fields.join(", ")})`);
|
|
461
|
+
// PAYLOAD validation first — before the session is even opened, and long before the lease: an
|
|
462
|
+
// invalid value must not briefly block a run.
|
|
463
|
+
let model;
|
|
464
|
+
if (patch.model !== undefined) {
|
|
465
|
+
const slash = patch.model.indexOf("/");
|
|
466
|
+
model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
|
|
467
|
+
if (!model) {
|
|
468
|
+
return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted specs`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
|
|
472
|
+
return invalid(`unknown thinking level "${patch.thinkingLevel}" — state().availableThinkingLevels lists what this session accepts`);
|
|
473
|
+
}
|
|
474
|
+
// A name is the client's own label; the only thing that cannot be one is nothing.
|
|
475
|
+
if (patch.name !== undefined && patch.name.trim() === "")
|
|
476
|
+
return invalid("a session name cannot be empty");
|
|
477
|
+
// Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
|
|
478
|
+
// rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
|
|
479
|
+
// the lease below, and this one is discarded.)
|
|
480
|
+
const existing = await sessions.openIfExists(session);
|
|
481
|
+
if (!existing)
|
|
482
|
+
return noSuchSession(session);
|
|
483
|
+
if (patch.leafEntryId !== undefined) {
|
|
484
|
+
// A target that cannot BE a leaf is a permanent payload error, not a session error — the same
|
|
485
|
+
// disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
486
|
+
// "everything published is a position" holds by construction rather than by two literals
|
|
487
|
+
// agreeing.
|
|
488
|
+
const entry = existing.getEntry(patch.leafEntryId);
|
|
489
|
+
if (!entry || !isNavigable(entry)) {
|
|
490
|
+
return invalid(entry
|
|
491
|
+
? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
|
|
492
|
+
: `entry "${patch.leafEntryId}" does not exist in session "${session}" — entries() lists the positions`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (patch.thinkingLevel !== undefined) {
|
|
496
|
+
// The same set `state()` showed the client. Reject here rather than record a level the run
|
|
497
|
+
// would not use. The read is guarded because this must never REJECT — the contract promises a
|
|
498
|
+
// SessionResult, so an unreadable chain has to arrive as a code.
|
|
499
|
+
let resolved;
|
|
500
|
+
try {
|
|
501
|
+
// Against the path this patch LANDS on: a leaf move is written first, and the branch it
|
|
502
|
+
// moves to can carry a model override of its own — validating on the path being left would
|
|
503
|
+
// reject a level the destination supports, and accept one it does not.
|
|
504
|
+
resolved = resolveSessionSettings(activePath(existing, patch.leafEntryId), b.models, b.defaults);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
return failed(error);
|
|
508
|
+
}
|
|
509
|
+
// An explicit model in the same patch wins over the one that path resolves to: it is applied
|
|
510
|
+
// after the move, so it is what the session ends up running.
|
|
511
|
+
const target = model ?? resolved.model;
|
|
512
|
+
const levels = model ? getSupportedThinkingLevels(model) : resolved.availableThinkingLevels;
|
|
513
|
+
if (!levels.includes(patch.thinkingLevel)) {
|
|
514
|
+
return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// The control plane's writes take the same lease as every run — a write must never race one
|
|
518
|
+
// (design §9).
|
|
519
|
+
const release = b.lease.tryAcquire(session);
|
|
520
|
+
if (!release)
|
|
521
|
+
return busy();
|
|
522
|
+
let applied;
|
|
523
|
+
try {
|
|
524
|
+
// HOW a record takes a property — order, the leaf pointer, the name pi rewrites — is the
|
|
525
|
+
// store's to know. This asks for the writes and is told what landed.
|
|
526
|
+
applied = await sessions.applyProperties(session, {
|
|
527
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
528
|
+
...(model ? { model: { provider: model.provider, id: model.id } } : {}),
|
|
529
|
+
...(patch.thinkingLevel !== undefined ? { thinkingLevel: patch.thinkingLevel } : {}),
|
|
530
|
+
...(patch.leafEntryId !== undefined ? { leafEntryId: patch.leafEntryId } : {}),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
// Opening the record failed — nothing was written; the same patch may succeed on retry. The
|
|
535
|
+
// lease is freed by the `finally` on the way out, once.
|
|
536
|
+
return failed(error);
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
release();
|
|
540
|
+
}
|
|
541
|
+
if (!applied)
|
|
542
|
+
return noSuchSession(session); // vanished in the window: same condition, same code
|
|
543
|
+
if (applied.landed.length > 0) {
|
|
544
|
+
// ONE event for the patch, built from what the RECORD holds. The settings pair rides along
|
|
545
|
+
// whenever anything but the name changed: model and thinking level are one setting, and a
|
|
546
|
+
// moved leaf can drop an override that used to apply.
|
|
547
|
+
let settings;
|
|
548
|
+
if (applied.path) {
|
|
549
|
+
try {
|
|
550
|
+
settings = resolveSessionSettings(applied.path, b.models, b.defaults);
|
|
394
551
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (!b) {
|
|
401
|
-
// No boundary wiring: rejected before acceptance; a capability-gating client never
|
|
402
|
-
// lands here.
|
|
403
|
-
return {
|
|
404
|
-
ok: false,
|
|
405
|
-
error: {
|
|
406
|
-
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
407
|
-
message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
|
|
408
|
-
retryable: false,
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
// Payload validation BEFORE the lease — an invalid value must not briefly block a run.
|
|
413
|
-
/** The durable write for set_model/set_thinking/navigate — undefined for compact (harness
|
|
414
|
-
* path). Answers the event to emit. */
|
|
415
|
-
let apply;
|
|
416
|
-
if (command.type === "set_model") {
|
|
417
|
-
const slash = command.model.indexOf("/");
|
|
418
|
-
const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
|
|
419
|
-
if (!model) {
|
|
420
|
-
return {
|
|
421
|
-
ok: false,
|
|
422
|
-
error: {
|
|
423
|
-
code: INVALID_COMMAND_CODE,
|
|
424
|
-
message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
|
|
425
|
-
retryable: false,
|
|
426
|
-
},
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
apply = async (s) => {
|
|
430
|
-
await s.appendModelChange(model.provider, model.id);
|
|
431
|
-
// Both halves: a new model can change which level executes. Nothing is re-recorded to
|
|
432
|
-
// make that true — the resolve reports it, so the preference survives a round trip.
|
|
433
|
-
const settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
434
|
-
return {
|
|
435
|
-
type: "state_changed",
|
|
436
|
-
timestamp: Date.now(),
|
|
437
|
-
// The CANONICAL spec, same string the durable entry and state() report — the event
|
|
438
|
-
// must not echo a client alias the other two surfaces would disagree with.
|
|
439
|
-
data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
|
|
440
|
-
};
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
else if (command.type === "set_thinking") {
|
|
444
|
-
// A payload that is not a level at all — invalid before any session question.
|
|
445
|
-
if (!THINKING_LEVELS.has(command.level)) {
|
|
446
|
-
return {
|
|
447
|
-
ok: false,
|
|
448
|
-
error: {
|
|
449
|
-
code: INVALID_COMMAND_CODE,
|
|
450
|
-
message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
|
|
451
|
-
retryable: false,
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
apply = async (s) => {
|
|
456
|
-
await s.appendThinkingLevelChange(command.level);
|
|
457
|
-
return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
else if (command.type === "navigate") {
|
|
461
|
-
apply = async (s) => {
|
|
462
|
-
// A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
|
|
463
|
-
// record, so an idempotent re-dispatch (a client retry, a UI firing on every
|
|
464
|
-
// selection) would otherwise grow the session by a record no plane publishes. The
|
|
465
|
-
// EVENT is emitted either way — it reports the resulting position, not the fact that
|
|
466
|
-
// a record was written, and a client that dispatched must not have to poll for it.
|
|
467
|
-
if ((await s.getLeafId()) !== command.targetId)
|
|
468
|
-
await s.moveTo(command.targetId);
|
|
469
|
-
// moveTo's postcondition IS "targetId is the leaf" (it validates, then sets); a
|
|
470
|
-
// failure throws and travels as boundary_command_failed, so a read-back could only
|
|
471
|
-
// re-report what this line already knows. The SETTINGS ride along because a move can
|
|
472
|
-
// change them — an override recorded on the branch just left stops applying, and a
|
|
473
|
-
// client tracking model/level from the event stream would otherwise show what the
|
|
474
|
-
// next turn will not use.
|
|
475
|
-
// The move is already durable here, so a settings read that throws (the new path is
|
|
476
|
-
// above a gap) must NOT turn into "nothing took effect": report the position without
|
|
477
|
-
// the settings and let `state()`'s rejection be where the broken chain surfaces.
|
|
478
|
-
let settings;
|
|
479
|
-
try {
|
|
480
|
-
settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
|
|
481
|
-
}
|
|
482
|
-
catch (error) {
|
|
483
|
-
// Absent rather than stale: the session cannot RUN with an unreadable chain either
|
|
484
|
-
// (the harness build walks the same path), so the next invoke fails visibly — this
|
|
485
|
-
// event does not need to carry a second signal for it.
|
|
486
|
-
log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
|
|
487
|
-
}
|
|
488
|
-
return {
|
|
489
|
-
type: "state_changed",
|
|
490
|
-
timestamp: Date.now(),
|
|
491
|
-
data: {
|
|
492
|
-
leafEntryId: command.targetId,
|
|
493
|
-
...(settings
|
|
494
|
-
? {
|
|
495
|
-
model: `${settings.model.provider}/${settings.model.id}`,
|
|
496
|
-
thinkingLevel: settings.thinkingLevel,
|
|
497
|
-
}
|
|
498
|
-
: {}),
|
|
499
|
-
},
|
|
500
|
-
};
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
// Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
|
|
504
|
-
// not minted into a ghost record. (Existence check before the lease — read-only; the
|
|
505
|
-
// WRITE handle is re-opened under the lease below, this one is discarded.)
|
|
506
|
-
const existing = await sessions.openIfExists(session);
|
|
507
|
-
if (!existing) {
|
|
508
|
-
return {
|
|
509
|
-
ok: false,
|
|
510
|
-
error: {
|
|
511
|
-
code: NO_SUCH_SESSION_CODE,
|
|
512
|
-
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
513
|
-
retryable: false,
|
|
514
|
-
},
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
if (command.type === "navigate") {
|
|
518
|
-
// A target that cannot BE a leaf is a permanent payload error, not a session error — the
|
|
519
|
-
// same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
520
|
-
// "everything published is navigable" holds by construction rather than by two literals
|
|
521
|
-
// agreeing.
|
|
522
|
-
const entry = await existing.getEntry(command.targetId);
|
|
523
|
-
if (!entry || !isNavigable(entry)) {
|
|
524
|
-
return {
|
|
525
|
-
ok: false,
|
|
526
|
-
error: {
|
|
527
|
-
code: INVALID_COMMAND_CODE,
|
|
528
|
-
message: entry
|
|
529
|
-
? `entry "${command.targetId}" is a leaf-move record — entries() does not publish those, and they are not positions; navigate to the entry it points at`
|
|
530
|
-
: `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
|
|
531
|
-
retryable: false,
|
|
532
|
-
},
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
if (command.type === "set_thinking") {
|
|
537
|
-
// The same set `state()` showed the client. Reject here rather than record a level the
|
|
538
|
-
// run would not use. The read is guarded because `dispatch` must never REJECT — the
|
|
539
|
-
// transport promises a SessionResult, so an unreadable chain has to arrive as a code.
|
|
540
|
-
let resolved;
|
|
541
|
-
try {
|
|
542
|
-
resolved = resolveSessionSettings((await activePathEntries(existing)), b.models, b.defaults);
|
|
543
|
-
}
|
|
544
|
-
catch (error) {
|
|
545
|
-
return {
|
|
546
|
-
ok: false,
|
|
547
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
const { model, availableThinkingLevels } = resolved;
|
|
551
|
-
if (!availableThinkingLevels.includes(command.level)) {
|
|
552
|
-
return {
|
|
553
|
-
ok: false,
|
|
554
|
-
error: {
|
|
555
|
-
code: INVALID_COMMAND_CODE,
|
|
556
|
-
message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
|
|
557
|
-
retryable: false,
|
|
558
|
-
},
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
// Boundary mutations are the control plane's only writers: same lease as every run — a
|
|
563
|
-
// mutation must never race one (design §9).
|
|
564
|
-
const release = b.lease.tryAcquire(session);
|
|
565
|
-
if (!release) {
|
|
566
|
-
return {
|
|
567
|
-
ok: false,
|
|
568
|
-
error: {
|
|
569
|
-
code: SESSION_BUSY_CODE,
|
|
570
|
-
message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
|
|
571
|
-
retryable: true,
|
|
572
|
-
},
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
if (command.type === "compact") {
|
|
576
|
-
// ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
|
|
577
|
-
// the dispatch open until it finishes made acceptance = outcome, the one exception to
|
|
578
|
-
// §5.2, and broke remote clients whose request timeouts are sized for control calls.
|
|
579
|
-
// The dispatch answers once the work is ADMITTED (lease held, harness built); the
|
|
580
|
-
// outcome travels as compaction_finished{summary|error|aborted}, the bounds contract watchers
|
|
581
|
-
// already rely on. Pre-acceptance failures (the harness build) still reject here.
|
|
582
|
-
// The admission step is EVERYTHING cheap and local: the harness build (the ONE
|
|
583
|
-
// canonical resolution of session overrides + auth) plus the compaction PREPARATION
|
|
584
|
-
// (a pure branch-read computation) — the boundary between "reject the dispatch" and
|
|
585
|
-
// "outcome travels as an event" sits where the work becomes asynchronous and
|
|
586
|
-
// expensive: the model call. "Nothing to compact" is thus a pre-acceptance answer,
|
|
587
|
-
// never a finished{error} dressed as a failure. The summarization runs through pi's
|
|
588
|
-
// compaction primitives instead of harness.compact() for exactly one reason: the
|
|
589
|
-
// harness surface passes no signal to its model call, so an in-flight compaction
|
|
590
|
-
// would be uncancellable — and `abort` needs a real door (run/compaction symmetry).
|
|
591
|
-
let harness;
|
|
592
|
-
try {
|
|
593
|
-
harness = await b.harnessFactory(session);
|
|
594
|
-
}
|
|
595
|
-
catch (error) {
|
|
596
|
-
release();
|
|
597
|
-
return {
|
|
598
|
-
ok: false,
|
|
599
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
const teardown = async () => {
|
|
603
|
-
try {
|
|
604
|
-
await harness.abort(); // fresh-harness discipline
|
|
605
|
-
}
|
|
606
|
-
catch (error) {
|
|
607
|
-
log.warn(`[fastagent] compaction harness teardown failed: ${String(error)}`);
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
let record;
|
|
611
|
-
let preparation;
|
|
612
|
-
try {
|
|
613
|
-
const bound = harnessSession(harness);
|
|
614
|
-
if (!bound)
|
|
615
|
-
throw new Error("harness has no bound session (factory invariant broken)");
|
|
616
|
-
record = bound;
|
|
617
|
-
const prep = prepareCompaction(await record.getBranch(), DEFAULT_COMPACTION_SETTINGS);
|
|
618
|
-
if (!prep.ok)
|
|
619
|
-
throw prep.error;
|
|
620
|
-
if (!prep.value) {
|
|
621
|
-
await teardown();
|
|
622
|
-
release();
|
|
623
|
-
// A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
|
|
624
|
-
// machine-distinguish "give up" from "re-dispatch once the session grows", and
|
|
625
|
-
// branching on message prose is forbidden by contract.
|
|
626
|
-
return {
|
|
627
|
-
ok: false,
|
|
628
|
-
error: {
|
|
629
|
-
code: NOTHING_TO_COMPACT_CODE,
|
|
630
|
-
message: "nothing to compact — the session has no compactable history yet; retry after more turns",
|
|
631
|
-
retryable: false,
|
|
632
|
-
},
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
preparation = prep.value;
|
|
636
|
-
}
|
|
637
|
-
catch (error) {
|
|
638
|
-
await teardown();
|
|
639
|
-
release();
|
|
640
|
-
return {
|
|
641
|
-
ok: false,
|
|
642
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
const door = new AbortController();
|
|
646
|
-
compacting.set(session, door); // admission complete: from here `abort` reaches the model call
|
|
647
|
-
emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
|
|
648
|
-
void (async () => {
|
|
649
|
-
let outcome;
|
|
650
|
-
try {
|
|
651
|
-
const done = await compact(preparation, b.models, harness.getModel(), command.instructions, door.signal, harness.getThinkingLevel(), SUMMARIZATION_RETRY_POLICY, {
|
|
652
|
-
// Retries are otherwise invisible between compaction_started and _finished —
|
|
653
|
-
// surface each backoff so a long gap is diagnosable (not confusable with a
|
|
654
|
-
// hang): as a session event for attached observers, as a warn for server logs.
|
|
655
|
-
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
|
|
656
|
-
log.warn(`[fastagent] compaction retry ${attempt}/${maxAttempts} in ${delayMs}ms (session ${session}): ${errorMessage}`);
|
|
657
|
-
emitOwn(session, {
|
|
658
|
-
type: "retry_scheduled",
|
|
659
|
-
timestamp: Date.now(),
|
|
660
|
-
data: { operation: "compaction", attempt, maxAttempts, delayMs, error: errorMessage },
|
|
661
|
-
});
|
|
662
|
-
},
|
|
663
|
-
});
|
|
664
|
-
if (!done.ok)
|
|
665
|
-
throw done.error;
|
|
666
|
-
await record.appendCompaction(done.value.summary, done.value.firstKeptEntryId, done.value.tokensBefore, done.value.details);
|
|
667
|
-
outcome = { summary: done.value.summary };
|
|
668
|
-
}
|
|
669
|
-
catch (error) {
|
|
670
|
-
// A deliberate stop is not a failure — run/compaction symmetry with
|
|
671
|
-
// run_settled{aborted}: the door's signal is the classification, same discipline
|
|
672
|
-
// as run abort attribution (a racing real failure still reads as aborted — the
|
|
673
|
-
// intent was live while the work resolved).
|
|
674
|
-
outcome = door.signal.aborted ? { aborted: true } : { error: String(error) };
|
|
675
|
-
}
|
|
676
|
-
await teardown();
|
|
677
|
-
// Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
|
|
678
|
-
// "finished ⇒ the lease is free and status is no longer compacting" must hold.
|
|
679
|
-
compacting.delete(session);
|
|
680
|
-
release();
|
|
681
|
-
emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
|
|
682
|
-
})();
|
|
683
|
-
return { ok: true };
|
|
684
|
-
}
|
|
685
|
-
try {
|
|
686
|
-
// The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
|
|
687
|
-
// be a stale snapshot of a run that completed in the window — appending to it would
|
|
688
|
-
// hang the override off an outdated leaf.
|
|
689
|
-
const fresh = await sessions.openIfExists(session);
|
|
690
|
-
if (!fresh) {
|
|
691
|
-
// Same real condition as the pre-lease check (the session vanished in the window):
|
|
692
|
-
// same code, same disposition — not a retryable internal error.
|
|
693
|
-
return {
|
|
694
|
-
ok: false,
|
|
695
|
-
error: {
|
|
696
|
-
code: NO_SUCH_SESSION_CODE,
|
|
697
|
-
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
698
|
-
retryable: false,
|
|
699
|
-
},
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
// Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
|
|
703
|
-
// and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
|
|
704
|
-
if (!apply)
|
|
705
|
-
throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
|
|
706
|
-
emitOwn(session, await apply(fresh));
|
|
707
|
-
}
|
|
708
|
-
catch (error) {
|
|
709
|
-
// The append failed before anything durable landed — "nothing took effect"; the same
|
|
710
|
-
// command may succeed on retry.
|
|
711
|
-
return {
|
|
712
|
-
ok: false,
|
|
713
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
finally {
|
|
717
|
-
release();
|
|
718
|
-
}
|
|
719
|
-
return { ok: true };
|
|
552
|
+
catch (error) {
|
|
553
|
+
// Already durable, so an unresolvable pair must NOT read as "nothing took effect": report
|
|
554
|
+
// the position without it and let the next invoke — which walks the same path — be where
|
|
555
|
+
// the fault surfaces.
|
|
556
|
+
log.warn(`[fastagent] session ${session}: updated, settings unresolvable: ${String(error)}`);
|
|
720
557
|
}
|
|
721
|
-
default:
|
|
722
|
-
// Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
|
|
723
|
-
// level answer, never an undefined body — the transport promises `ok: false` shapes.
|
|
724
|
-
return {
|
|
725
|
-
ok: false,
|
|
726
|
-
error: {
|
|
727
|
-
code: INVALID_COMMAND_CODE,
|
|
728
|
-
message: `unknown command type "${String(command.type)}"`,
|
|
729
|
-
retryable: false,
|
|
730
|
-
},
|
|
731
|
-
};
|
|
732
558
|
}
|
|
559
|
+
emitOwn(session, {
|
|
560
|
+
type: "state_changed",
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
data: {
|
|
563
|
+
...(applied.landed.includes("leafEntryId") ? { leafEntryId: applied.leafEntryId } : {}),
|
|
564
|
+
...(settings && applied.landed.some((f) => f !== "name")
|
|
565
|
+
? { model: `${settings.model.provider}/${settings.model.id}`, thinkingLevel: settings.thinkingLevel }
|
|
566
|
+
: {}),
|
|
567
|
+
...(applied.landed.includes("name") && applied.name ? { name: applied.name } : {}),
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (applied.failure !== undefined) {
|
|
572
|
+
// `boundary_command_failed` means nothing durable landed. When something did, the client needs
|
|
573
|
+
// a different sentence — and the fields, so it knows what its retry would repeat.
|
|
574
|
+
return applied.landed.length === 0
|
|
575
|
+
? failed(applied.failure)
|
|
576
|
+
: {
|
|
577
|
+
ok: false,
|
|
578
|
+
error: {
|
|
579
|
+
code: PARTIAL_UPDATE_CODE,
|
|
580
|
+
message: `applied ${applied.landed.join(", ")}, then failed: ${String(applied.failure)} — read state() before retrying`,
|
|
581
|
+
retryable: false,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return { ok: true };
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* ACCEPT-FAST compaction: a full model call (tens of seconds is normal), so holding the call open
|
|
589
|
+
* until it finishes would make acceptance = outcome — the one exception to §5.2, and what broke
|
|
590
|
+
* remote clients whose request timeouts are sized for control calls. This answers once the work is
|
|
591
|
+
* ADMITTED (lease held, session bound); the outcome travels as
|
|
592
|
+
* `compaction_finished{summary|error|aborted}`.
|
|
593
|
+
*
|
|
594
|
+
* Admission is everything cheap and local: binding the session (the ONE canonical resolution of
|
|
595
|
+
* overrides + auth) plus the compaction PREPARATION, a pure branch read. The boundary between
|
|
596
|
+
* "reject" and "the outcome travels as an event" sits where the work becomes asynchronous and
|
|
597
|
+
* expensive: the model call. "Nothing to compact" is therefore a pre-acceptance answer, never a
|
|
598
|
+
* finished{error} dressed as a failure — pi reports it as a throw from compact(), too late.
|
|
599
|
+
*/
|
|
600
|
+
const compactOf = async (session, instructions) => {
|
|
601
|
+
const b = boundary?.();
|
|
602
|
+
if (!b)
|
|
603
|
+
return unsupported("compact()");
|
|
604
|
+
const existing = await sessions.openIfExists(session);
|
|
605
|
+
if (!existing)
|
|
606
|
+
return noSuchSession(session);
|
|
607
|
+
const release = b.lease.tryAcquire(session);
|
|
608
|
+
if (!release)
|
|
609
|
+
return busy();
|
|
610
|
+
let bound;
|
|
611
|
+
try {
|
|
612
|
+
bound = await b.sessionFactory(session);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
release();
|
|
616
|
+
return failed(error);
|
|
617
|
+
}
|
|
618
|
+
const teardown = () => {
|
|
619
|
+
try {
|
|
620
|
+
bound.dispose();
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
try {
|
|
627
|
+
// The SAME settings pi will use inside compact(): asking with different thresholds would
|
|
628
|
+
// either reject a compaction pi would have run, or admit one it refuses — and its refusal
|
|
629
|
+
// arrives too late to be a pre-acceptance answer.
|
|
630
|
+
const path = bound.sessionManager.getBranch();
|
|
631
|
+
const prep = prepareCompaction(path, bound.settingsManager.getCompactionSettings());
|
|
632
|
+
if (!prep.ok)
|
|
633
|
+
throw prep.error;
|
|
634
|
+
// Empty is the same answer as absent: pi ships two prepareCompaction implementations
|
|
635
|
+
// (agent-core answers with a Result, coding-agent with undefined) and they disagree on which
|
|
636
|
+
// one an unsummarizable session gets. What they agree on is the CONTENT — no messages to
|
|
637
|
+
// summarize — so that is what the gate reads.
|
|
638
|
+
if (!prep.value || prep.value.messagesToSummarize.length === 0) {
|
|
639
|
+
teardown();
|
|
640
|
+
release();
|
|
641
|
+
// A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
|
|
642
|
+
// machine-distinguish "give up" from "call again once the session grows", and branching on
|
|
643
|
+
// message prose is forbidden by contract.
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
error: {
|
|
647
|
+
code: NOTHING_TO_COMPACT_CODE,
|
|
648
|
+
message: "nothing to compact — the session has no compactable history yet; retry after more turns",
|
|
649
|
+
retryable: false,
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
teardown();
|
|
656
|
+
release();
|
|
657
|
+
return failed(error);
|
|
658
|
+
}
|
|
659
|
+
// The door is the session's own compaction abort — a real one, unlike a summarization call with
|
|
660
|
+
// no signal: `abort` must reach the model call (run/compaction symmetry).
|
|
661
|
+
//
|
|
662
|
+
// pi builds the controller that makes it abortable AFTER an internal await, so an abort arriving
|
|
663
|
+
// in that window would find nothing to cancel and the compaction would run to completion — the
|
|
664
|
+
// client's cancel silently doing nothing. The intent is latched and re-applied until it takes
|
|
665
|
+
// (`isCompacting` reports when it has).
|
|
666
|
+
//
|
|
667
|
+
// The retry is DEFENSIVE: the window is one await wide, and the test below lands after it, so
|
|
668
|
+
// this loop is not what makes that test pass. It is here because the window is on the code path,
|
|
669
|
+
// not because it has been observed.
|
|
670
|
+
let aborted = false;
|
|
671
|
+
let running = true; // cleared when the compaction settles, however it settles
|
|
672
|
+
const applyAbort = async () => {
|
|
673
|
+
// WAIT for the controller rather than requiring it: an abort that arrives before pi builds one
|
|
674
|
+
// sees isCompacting false, and a loop that only runs WHILE compacting would exit immediately —
|
|
675
|
+
// leaving the intent unapplied in exactly the window it exists for.
|
|
676
|
+
for (let attempt = 0; attempt < 200 && running; attempt++) {
|
|
677
|
+
if (bound.isCompacting) {
|
|
678
|
+
bound.abortCompaction();
|
|
679
|
+
if (!bound.isCompacting)
|
|
680
|
+
return; // it took
|
|
681
|
+
}
|
|
682
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
compacting.set(session, {
|
|
686
|
+
abort: () => {
|
|
687
|
+
aborted = true;
|
|
688
|
+
bound.abortCompaction();
|
|
689
|
+
void applyAbort();
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
|
|
693
|
+
void (async () => {
|
|
694
|
+
let outcome;
|
|
695
|
+
// Retries are otherwise invisible between compaction_started and _finished — surface each
|
|
696
|
+
// backoff so a long gap is diagnosable (not confusable with a hang): as a session event for
|
|
697
|
+
// attached observers, as a warn for server logs.
|
|
698
|
+
const unsub = bound.subscribe((event) => {
|
|
699
|
+
if (event.type !== "summarization_retry_scheduled")
|
|
700
|
+
return;
|
|
701
|
+
log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
|
|
702
|
+
emitOwn(session, {
|
|
703
|
+
type: "retry_scheduled",
|
|
704
|
+
timestamp: Date.now(),
|
|
705
|
+
data: {
|
|
706
|
+
operation: "compaction",
|
|
707
|
+
attempt: event.attempt,
|
|
708
|
+
maxAttempts: event.maxAttempts,
|
|
709
|
+
delayMs: event.delayMs,
|
|
710
|
+
error: event.errorMessage,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
try {
|
|
715
|
+
const done = await bound.compact(instructions);
|
|
716
|
+
outcome = { summary: done.summary };
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
// A deliberate stop is not a failure — run/compaction symmetry with run_settled{aborted}:
|
|
720
|
+
// the intent is the classification, same discipline as run abort attribution (a racing real
|
|
721
|
+
// failure still reads as aborted).
|
|
722
|
+
outcome = aborted ? { aborted: true } : { error: String(error) };
|
|
723
|
+
}
|
|
724
|
+
running = false;
|
|
725
|
+
unsub();
|
|
726
|
+
teardown();
|
|
727
|
+
// Release BEFORE emitting finished: a watcher seeing finished may act next — "finished ⇒ the
|
|
728
|
+
// lease is free and status is no longer compacting" must hold.
|
|
729
|
+
compacting.delete(session);
|
|
730
|
+
release();
|
|
731
|
+
emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
|
|
732
|
+
})();
|
|
733
|
+
return { ok: true };
|
|
734
|
+
};
|
|
735
|
+
/**
|
|
736
|
+
* Copy a history into a new session. IDEMPOTENT by construction: `into` is the caller's id, so a
|
|
737
|
+
* repeat of a fork that already landed answers `ok: true` and writes nothing — a client retrying a
|
|
738
|
+
* request whose response it never saw does not get a second record. Provenance is what makes that
|
|
739
|
+
* safe rather than merely quiet: the same `into` naming a session that came from somewhere else is
|
|
740
|
+
* a rejection, not an overwrite.
|
|
741
|
+
*/
|
|
742
|
+
const forkOf = async (options) => {
|
|
743
|
+
const { from, at, into } = options;
|
|
744
|
+
/** WHICH fork this is: source + branch point. Two forks of one session at different entries are
|
|
745
|
+
* different requests, so a retry of one must not be answered by the other. */
|
|
746
|
+
const provenance = `${from}@${at}`;
|
|
747
|
+
const b = boundary?.();
|
|
748
|
+
if (!b)
|
|
749
|
+
return unsupported("fork()");
|
|
750
|
+
// An id no client could then open: the empty string, `.` and `..` are not URL path segments
|
|
751
|
+
// (isAddressableSession), so minting one would put a row in list() that nothing can address —
|
|
752
|
+
// listed, unopenable by the client that just listed it.
|
|
753
|
+
if (!isAddressableSession(into)) {
|
|
754
|
+
return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
|
|
755
|
+
}
|
|
756
|
+
const source = await sessions.openIfExists(from);
|
|
757
|
+
if (!source)
|
|
758
|
+
return noSuchSession(from);
|
|
759
|
+
// The entry predicate is the one `entries()` publishes by, so "everything published is forkable"
|
|
760
|
+
// holds by construction — the same argument the leaf move makes.
|
|
761
|
+
const entry = source.getEntry(at);
|
|
762
|
+
if (!entry || !isNavigable(entry)) {
|
|
763
|
+
return invalid(`entry "${at}" is not a forkable position in session "${from}" — entries() lists the ids`);
|
|
764
|
+
}
|
|
765
|
+
const existingTarget = await sessions.openIfExists(into);
|
|
766
|
+
if (existingTarget) {
|
|
767
|
+
// Already forked from HERE: the request already happened, so answering ok is the truth rather
|
|
768
|
+
// than a convenience. Anything else under that id is a different history, and saying yes would
|
|
769
|
+
// be the id lying about what it holds.
|
|
770
|
+
return forkProvenance(existingTarget) === provenance
|
|
771
|
+
? { ok: true }
|
|
772
|
+
: invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
|
|
773
|
+
}
|
|
774
|
+
// BOTH ends. The source lease keeps the copy from reading a history a run is mid-write on; the
|
|
775
|
+
// destination lease closes the window the existence check above leaves open — an invoke creating
|
|
776
|
+
// `into`, or a second fork from a DIFFERENT source (whose source lease is another key entirely),
|
|
777
|
+
// otherwise lands between that check and this write. tryAcquire never blocks, so taking two
|
|
778
|
+
// cannot deadlock.
|
|
779
|
+
const release = b.lease.tryAcquire(from);
|
|
780
|
+
if (!release)
|
|
781
|
+
return busy();
|
|
782
|
+
const releaseInto = b.lease.tryAcquire(into);
|
|
783
|
+
if (!releaseInto) {
|
|
784
|
+
release();
|
|
785
|
+
return busy();
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
// Holding the lease is not the same as having looked: re-asked under it, as the update path
|
|
789
|
+
// re-opens its record, so an id taken inside the window is a payload error rather than a
|
|
790
|
+
// store failure the client would read as retryable.
|
|
791
|
+
const raced = await sessions.openIfExists(into);
|
|
792
|
+
if (raced) {
|
|
793
|
+
return forkProvenance(raced) === provenance
|
|
794
|
+
? { ok: true }
|
|
795
|
+
: invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
|
|
796
|
+
}
|
|
797
|
+
await sessions.fork(from, at, into, provenance);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
// Nothing durable landed: the copy is staged and published by rename.
|
|
801
|
+
return failed(error);
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
releaseInto();
|
|
805
|
+
release();
|
|
806
|
+
}
|
|
807
|
+
return { ok: true };
|
|
808
|
+
};
|
|
809
|
+
const deleteOf = async (session) => {
|
|
810
|
+
const b = boundary?.();
|
|
811
|
+
if (!b)
|
|
812
|
+
return unsupported("delete()");
|
|
813
|
+
const existing = await sessions.openIfExists(session);
|
|
814
|
+
if (!existing)
|
|
815
|
+
return noSuchSession(session);
|
|
816
|
+
// The same lease as a run: a delete racing one would pull the record out from under it.
|
|
817
|
+
const release = b.lease.tryAcquire(session);
|
|
818
|
+
if (!release)
|
|
819
|
+
return busy();
|
|
820
|
+
try {
|
|
821
|
+
// It was there before the lease and is gone now — the same real condition the check above
|
|
822
|
+
// answers, so the same code.
|
|
823
|
+
if (!(await sessions.delete(session)))
|
|
824
|
+
return noSuchSession(session);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
// A delete that throws left the record in place.
|
|
828
|
+
return failed(error);
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
release();
|
|
832
|
+
}
|
|
833
|
+
// The session is gone, so its live streams have nothing left to report: end them rather than
|
|
834
|
+
// hold connections open on a record that no longer exists. A client's reconnect then reads an
|
|
835
|
+
// empty `state()`, which is the truth.
|
|
836
|
+
for (const sub of [...(subscribers.get(session) ?? [])])
|
|
837
|
+
sub.close();
|
|
838
|
+
subscribers.delete(session);
|
|
839
|
+
return { ok: true };
|
|
840
|
+
};
|
|
841
|
+
const control = {
|
|
842
|
+
capabilities: reads.capabilities,
|
|
843
|
+
commands: reads.commands,
|
|
844
|
+
sessions: {
|
|
845
|
+
// The one read that may REJECT (design §13): `[]` is what a deployment with no sessions
|
|
846
|
+
// answers, so a store that cannot be read must not borrow that shape. The transport turns the
|
|
847
|
+
// throw into a coded non-2xx; nothing here swallows it.
|
|
848
|
+
list: () => sessions.list(),
|
|
849
|
+
fork: forkOf,
|
|
850
|
+
// A PURE BINDING: an id and the closures above it. Nothing is checked here — the calls answer
|
|
851
|
+
// that, each in its own vocabulary — and nothing is cached, so two handles for one id are
|
|
852
|
+
// interchangeable.
|
|
853
|
+
get: (session) => ({
|
|
854
|
+
id: session,
|
|
855
|
+
state: () => reads.state(session),
|
|
856
|
+
entries: (options) => reads.entries(session, options),
|
|
857
|
+
events: () => reads.events(session),
|
|
858
|
+
update: (patch) => updateOf(session, patch),
|
|
859
|
+
steer: (prompt) => runAction(session, { type: "steer", prompt }),
|
|
860
|
+
followUp: (prompt) => runAction(session, { type: "follow_up", prompt }),
|
|
861
|
+
abort: () => runAction(session, { type: "abort" }),
|
|
862
|
+
compact: (options) => compactOf(session, options?.instructions),
|
|
863
|
+
delete: () => deleteOf(session),
|
|
864
|
+
}),
|
|
733
865
|
},
|
|
734
866
|
};
|
|
735
867
|
return { control, observer };
|