@fungi.computer/truffle 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +282 -34
- package/dist/auth-cli.d.ts +10 -0
- package/dist/auth-cli.js +36 -14
- package/dist/fungi-account-command.d.ts +1 -1
- package/dist/fungi-account-command.js +6 -3
- package/dist/index.d.ts +10 -2
- package/dist/index.js +8 -2
- package/dist/internal/image-fit-metafile.json +106 -0
- package/dist/internal/local-image-fit-worker.mjs +960 -0
- package/dist/internal/private-workerd-sandbox-worker.mjs +11 -12
- package/dist/internal/sandbox-metafile.json +7 -7
- package/dist/local-archive-handoff.d.ts +12 -0
- package/dist/local-archive-handoff.js +21 -0
- package/dist/local-extensions.d.ts +58 -12
- package/dist/local-extensions.js +195 -55
- package/dist/local-image-fit.d.ts +30 -0
- package/dist/local-image-fit.js +105 -0
- package/dist/local-models.d.ts +4 -2
- package/dist/local-models.js +11 -6
- package/dist/local-session-archives.d.ts +8 -0
- package/dist/local-session-archives.js +152 -0
- package/dist/local-skill-source.d.ts +18 -3
- package/dist/local-skill-source.js +216 -140
- package/dist/log-event.d.ts +7 -0
- package/dist/log-event.js +8 -4
- package/dist/private-bounded-response.d.ts +2 -0
- package/dist/private-bounded-response.js +41 -0
- package/dist/private-file-credential-store.d.ts +28 -3
- package/dist/private-file-credential-store.js +57 -9
- package/dist/private-files-patch.d.ts +2 -1
- package/dist/private-files-patch.js +21 -3
- package/dist/private-fungi-account.d.ts +40 -23
- package/dist/private-fungi-account.js +201 -21
- package/dist/private-fungi-mcp-connection.d.ts +4 -0
- package/dist/private-fungi-mcp-connection.js +25 -6
- package/dist/private-fungi-mcp-selection-schema.d.ts +7 -0
- package/dist/private-fungi-mcp-selection-schema.js +15 -0
- package/dist/private-fungi-mcp-selection.d.ts +6 -6
- package/dist/private-fungi-mcp-selection.js +29 -16
- package/dist/private-fungi-model-api.js +4 -4
- package/dist/private-fungi-model-catalog.d.ts +10 -0
- package/dist/private-fungi-model-catalog.js +10 -2
- package/dist/private-fungi-model-provider.d.ts +1 -1
- package/dist/private-fungi-model-provider.js +6 -1
- package/dist/private-fungi-pending-logouts.d.ts +1 -1
- package/dist/private-fungi-pending-logouts.js +1 -2
- package/dist/private-fungi-service-logout.d.ts +44 -0
- package/dist/private-fungi-service-logout.js +59 -0
- package/dist/private-invocation-computer.d.ts +24 -3
- package/dist/private-invocation-computer.js +101 -62
- package/dist/private-invocation-files.d.ts +16 -1
- package/dist/private-invocation-files.js +154 -83
- package/dist/private-local-acp-stdio.js +3 -0
- package/dist/private-local-admission-gate.d.ts +14 -0
- package/dist/private-local-admission-gate.js +52 -0
- package/dist/private-local-agent-control.d.ts +101 -0
- package/dist/private-local-agent-control.js +30 -0
- package/dist/private-local-agent-default-model.d.ts +12 -0
- package/dist/private-local-agent-default-model.js +31 -0
- package/dist/private-local-auth-rpc.d.ts +67 -7
- package/dist/private-local-auth-rpc.js +140 -17
- package/dist/private-local-bwrap.d.ts +13 -0
- package/dist/private-local-bwrap.js +70 -0
- package/dist/private-local-client-version.d.ts +5 -0
- package/dist/private-local-client-version.js +43 -0
- package/dist/private-local-computer-attach.d.ts +3 -3
- package/dist/private-local-computer-attach.js +17 -3
- package/dist/private-local-computer-processes.d.ts +6 -3
- package/dist/private-local-computer-processes.js +55 -31
- package/dist/private-local-config-root.d.ts +21 -0
- package/dist/private-local-config-root.js +19 -0
- package/dist/private-local-containment.d.ts +51 -0
- package/dist/private-local-containment.js +161 -0
- package/dist/private-local-delegation.d.ts +10 -3
- package/dist/private-local-delegation.js +70 -15
- package/dist/private-local-effective-model.d.ts +9 -1
- package/dist/private-local-effective-model.js +3 -0
- package/dist/private-local-exec-environment.d.ts +16 -0
- package/dist/private-local-exec-environment.js +11 -0
- package/dist/private-local-linux-sandbox.d.ts +8 -0
- package/dist/private-local-linux-sandbox.js +67 -0
- package/dist/private-local-mail.d.ts +4 -2
- package/dist/private-local-mail.js +41 -33
- package/dist/private-local-mesh-enrollment.d.ts +34 -0
- package/dist/private-local-mesh-enrollment.js +188 -0
- package/dist/private-local-mesh-links.d.ts +21 -0
- package/dist/private-local-mesh-links.js +229 -0
- package/dist/private-local-mesh-record.d.ts +120 -0
- package/dist/private-local-mesh-record.js +55 -0
- package/dist/private-local-process-identity.d.ts +20 -0
- package/dist/private-local-process-identity.js +40 -0
- package/dist/private-local-runtime-host.d.ts +64 -13
- package/dist/private-local-runtime-host.js +361 -172
- package/dist/private-local-sandbox-policy.d.ts +21 -0
- package/dist/private-local-sandbox-policy.js +121 -0
- package/dist/private-local-sandbox.d.ts +37 -0
- package/dist/private-local-sandbox.js +114 -0
- package/dist/private-local-seatbelt.d.ts +24 -0
- package/dist/private-local-seatbelt.js +68 -0
- package/dist/private-local-service-contract.d.ts +91 -11
- package/dist/private-local-service-discovery.d.ts +129 -13
- package/dist/private-local-service-discovery.js +148 -47
- package/dist/private-local-service-lease.d.ts +16 -0
- package/dist/private-local-service-lease.js +105 -0
- package/dist/private-local-service-listener.d.ts +9 -2
- package/dist/private-local-service-listener.js +260 -64
- package/dist/private-local-service-log.d.ts +128 -0
- package/dist/private-local-service-log.js +321 -0
- package/dist/private-local-service-owner.d.ts +87 -1519
- package/dist/private-local-service-owner.js +1069 -71
- package/dist/private-local-service-process.js +3 -1
- package/dist/private-local-service-termination.d.ts +7 -0
- package/dist/private-local-service-termination.js +67 -0
- package/dist/private-local-service.d.ts +31 -2
- package/dist/private-local-service.js +402 -126
- package/dist/private-local-session-discovery.d.ts +52 -5
- package/dist/private-local-session-discovery.js +132 -11
- package/dist/private-local-shiitake-host.js +2 -2
- package/dist/private-local-startup-model.d.ts +51 -0
- package/dist/private-local-startup-model.js +86 -0
- package/dist/private-local-stop-all.d.ts +27 -0
- package/dist/private-local-stop-all.js +26 -0
- package/dist/private-local-ui-attachment.d.ts +19 -0
- package/dist/private-local-ui-attachment.js +71 -0
- package/dist/private-local-worker-server.d.ts +1 -0
- package/dist/private-local-worker-server.js +13 -5
- package/dist/private-mcp-form-contract.d.ts +186 -0
- package/dist/private-mcp-form-contract.js +53 -0
- package/dist/private-mcp-form-elicitation.d.ts +18 -0
- package/dist/private-mcp-form-elicitation.js +97 -0
- package/dist/private-mcp-form-session.d.ts +172 -0
- package/dist/private-mcp-form-session.js +148 -0
- package/dist/private-openai-claims.d.ts +30 -0
- package/dist/private-openai-claims.js +69 -0
- package/dist/private-oyster-mcp-auth.d.ts +13 -0
- package/dist/private-oyster-mcp-auth.js +57 -0
- package/dist/private-provider-rejection.d.ts +15 -0
- package/dist/private-provider-rejection.js +24 -0
- package/dist/private-sqlite-owner.d.ts +34 -2
- package/dist/private-sqlite-owner.js +103 -37
- package/dist/private-team-computers.d.ts +4 -4
- package/dist/private-team-computers.js +11 -2
- package/dist/seatbelt/safe-base.sbpl +79 -0
- package/dist/subscription-activation.d.ts +9 -0
- package/dist/subscription-activation.js +62 -0
- package/dist/subscription-identity.d.ts +23 -0
- package/dist/subscription-identity.js +124 -0
- package/dist/subscription-turn.d.ts +17 -0
- package/dist/subscription-turn.js +143 -0
- package/dist/team-apps.d.ts +2 -2
- package/dist/team-apps.js +7 -2
- package/native/bin/LICENSE.bubblewrap +502 -0
- package/native/bin/LICENSE.libcap +112 -0
- package/native/bin/LICENSE.sandbox-runtime +201 -0
- package/native/bin/SOURCE.bubblewrap.tar.xz +0 -0
- package/native/bin/bwrap-build-source/Dockerfile +48 -0
- package/native/bin/bwrap-build-source/README.md +96 -0
- package/native/bin/bwrap-build-source/build.mjs +52 -0
- package/native/bin/bwrap-build-source/linux-arm64.ini +14 -0
- package/native/bin/bwrap-build-source/pin.mjs +21 -0
- package/native/bin/bwrap-build-source/record.mjs +74 -0
- package/native/bin/bwrap-build-source/vendor/LICENSE.sandbox-runtime +201 -0
- package/native/bin/bwrap-build-source/vendor/README.md +23 -0
- package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +280 -0
- package/native/bin/bwrap-build.json +44 -0
- package/native/bin/fungi-bwrap +0 -0
- package/native/bin/fungi-bwrap.sha256 +1 -0
- package/native/bin/fungi-seccomp.bpf +0 -0
- package/native/bin/fungi-seccomp.bpf.sha256 +1 -0
- package/package.json +44 -31
- package/dist/private-local-model-settings.d.ts +0 -5
- package/dist/private-local-model-settings.js +0 -31
- package/dist/private-local-subagents.d.ts +0 -27
- package/dist/private-local-subagents.js +0 -77
|
@@ -1,45 +1,280 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
1
2
|
import { logEvent } from "./log-event.js";
|
|
2
|
-
import {
|
|
3
|
+
import { createPrivateModels } from "./local-models.js";
|
|
4
|
+
import { capturePrivateLocalConfigRoot, } from "./private-local-config-root.js";
|
|
5
|
+
import { AdmissionGate } from "./private-local-admission-gate.js";
|
|
6
|
+
import { readFungiMcpSelection, readProjectFungiMcpSelection, sameFungiMcpConnection, FungiMcpSelectionConflict, } from "./private-fungi-mcp-selection.js";
|
|
3
7
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { AgentCreateRefused, AgentArchiveRefused, } from "@fungi.computer/shiitake/agent-control";
|
|
10
|
+
import { localExtensions, LocalExtensionError, } from "./local-extensions.js";
|
|
4
11
|
import { readFile, realpath } from "node:fs/promises";
|
|
5
12
|
import writeFileAtomic from "write-file-atomic";
|
|
6
13
|
import { z } from "zod";
|
|
7
14
|
import * as Schema from "effect/Schema";
|
|
8
15
|
import { selectionSchema } from "./private-local-service-discovery.js";
|
|
9
16
|
import { createShiitakeHttpRoutes } from "@fungi.computer/shiitake/http";
|
|
10
|
-
import { listPrivateLocalAgentStores, readPrivateLocalSessionSummaries, } from "./private-local-session-discovery.js";
|
|
17
|
+
import { listPrivateLocalAgentStores, LocalStoreSchemaError, readPrivateLocalAgentProfile, readPrivateLocalSessionSummaries, isArchivedStore, unreadableStoreReason, } from "./private-local-session-discovery.js";
|
|
11
18
|
import { createPrivateLocalSessionRequestId, openPrivateLocalRuntimeHost, resolvePrivateLocalCreateStore, } from "./private-local-runtime-host.js";
|
|
19
|
+
import { parsePrivateLocalExecPath } from "./private-local-exec-path.js";
|
|
20
|
+
import { createLocalMeshLinks, localMeshLinkKey, } from "./private-local-mesh-links.js";
|
|
21
|
+
import { agentMailAddressSchema, machineAgentRequestSchema, machineDirectorySchema, } from "@fungi.computer/seam/machine-link-protocol";
|
|
22
|
+
import { parseAccepted } from "@fungi.computer/shiitake/client";
|
|
23
|
+
import { commandSettlementSchema } from "@fungi.computer/shiitake/fleet";
|
|
24
|
+
import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
|
|
12
25
|
export class LocalSessionPathConflict extends Error {
|
|
13
26
|
name = "LocalSessionPathConflict";
|
|
14
27
|
constructor() {
|
|
15
28
|
super("PATH differs for this live session; close its clients and restart the Local service before reconnecting from another toolchain");
|
|
16
29
|
}
|
|
17
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* A folder holds several Agent stores and the request chose none of them.
|
|
33
|
+
* The service never picks one for the caller; --agent, --db or the
|
|
34
|
+
* landing screen does.
|
|
35
|
+
*/
|
|
36
|
+
export class LocalAgentSelectionAmbiguous extends Error {
|
|
37
|
+
name = "LocalAgentSelectionAmbiguous";
|
|
38
|
+
agentIds;
|
|
39
|
+
constructor(agentIds) {
|
|
40
|
+
super(`Several Agents in this folder (${agentIds.join(", ")}); choose one with --agent <id> or --db <path>`);
|
|
41
|
+
this.agentIds = agentIds;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
18
44
|
// Reopen inputs only. Credentials remain with Local's account custodian.
|
|
19
45
|
const recoveryOwnerSchema = selectionSchema
|
|
20
|
-
.omit({ clientPath: true, sessionId: true })
|
|
46
|
+
.omit({ clientPath: true, sessionId: true, uiAttachmentLifetime: true })
|
|
21
47
|
.extend({ databasePath: z.string().min(1) });
|
|
22
48
|
const decodeRecoveryFile = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Schema.Unknown)));
|
|
49
|
+
class LocalRecoveryPersistenceDisabled extends Error {
|
|
50
|
+
reason;
|
|
51
|
+
name = "LocalRecoveryPersistenceDisabled";
|
|
52
|
+
constructor(reason, cause) {
|
|
53
|
+
super("Recovery persistence is disabled: the manifest could not be loaded", {
|
|
54
|
+
cause,
|
|
55
|
+
});
|
|
56
|
+
this.reason = reason;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
23
59
|
/** One service owns each store, independently of how many clients select it. */
|
|
24
|
-
export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHost,
|
|
60
|
+
export function createLocalServiceOwners(openRuntime = (input) => openPrivateLocalRuntimeHost(input, createPrivateModels(input.configRoot.credentialPath)), recoveryDirectory,
|
|
61
|
+
/** The installed-extension owner that decides what each Agent holds. */
|
|
62
|
+
suppliedExtensions,
|
|
63
|
+
/** How often idle Agents are checked; one is released on its second idle check. */
|
|
64
|
+
idleSweepMs = 60_000,
|
|
65
|
+
/** Read the recovery manifest; tests can hold this I/O independently of opens. */
|
|
66
|
+
readRecoveryManifest = (path) => readFile(path, "utf8"),
|
|
67
|
+
/** Report only the classification, never the manifest or exception text. */
|
|
68
|
+
onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot()) {
|
|
69
|
+
const extensions = suppliedExtensions ?? localExtensions({ home: configRoot.home });
|
|
25
70
|
const recoveryPath = recoveryDirectory === undefined
|
|
26
71
|
? undefined
|
|
27
72
|
: resolve(recoveryDirectory, "owners.json");
|
|
28
73
|
const recovery = new Map();
|
|
74
|
+
let loadingRecovery;
|
|
29
75
|
let saving = Promise.resolve();
|
|
30
76
|
const emptySessions = new Map();
|
|
31
77
|
const owners = new Map();
|
|
32
|
-
const
|
|
78
|
+
const roots = new Map();
|
|
79
|
+
const meshDirectories = new Map();
|
|
80
|
+
async function meshAdmission(localAgentId, recipientAgentId) {
|
|
81
|
+
for (const { record, agents } of meshDirectories.values()) {
|
|
82
|
+
const binding = record.bindings.find((candidate) => candidate.agentId === recipientAgentId);
|
|
83
|
+
if (binding !== undefined &&
|
|
84
|
+
record.agents[binding.selection]?.localAgentId === localAgentId &&
|
|
85
|
+
(await mesh.isCurrent(record)))
|
|
86
|
+
return agents;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const mesh = createLocalMeshLinks({
|
|
91
|
+
credentialPath: configRoot.credentialPath,
|
|
92
|
+
receive: async (record, envelope) => {
|
|
93
|
+
if (envelope.kind === "machineDirectory") {
|
|
94
|
+
const address = z
|
|
95
|
+
.strictObject({ teamId: z.string(), deviceId: z.string() })
|
|
96
|
+
.parse(envelope.address);
|
|
97
|
+
if (address.teamId !== record.scopeId ||
|
|
98
|
+
address.deviceId !== record.deviceId)
|
|
99
|
+
return "refused";
|
|
100
|
+
const projection = Schema.decodeUnknownSync(machineDirectorySchema, {
|
|
101
|
+
onExcessProperty: "error",
|
|
102
|
+
})(envelope.event);
|
|
103
|
+
meshDirectories.set(localMeshLinkKey(record), {
|
|
104
|
+
record,
|
|
105
|
+
agents: projection.agents,
|
|
106
|
+
});
|
|
107
|
+
return "accepted";
|
|
108
|
+
}
|
|
109
|
+
const address = Schema.decodeUnknownSync(agentMailAddressSchema, {
|
|
110
|
+
onExcessProperty: "error",
|
|
111
|
+
})(envelope.address);
|
|
112
|
+
const binding = record.bindings.find((candidate) => candidate.agentId === address.agentId);
|
|
113
|
+
const selected = binding === undefined ? undefined : record.agents[binding.selection];
|
|
114
|
+
if (address.scopeId !== record.scopeId || selected === undefined)
|
|
115
|
+
return "refused";
|
|
116
|
+
const { owner, release } = await directory(record.invocationRoot).owner(selected.localAgentId, new AbortController().signal);
|
|
117
|
+
try {
|
|
118
|
+
if (owner.databasePath !== selected.databasePath)
|
|
119
|
+
return "refused";
|
|
120
|
+
if (envelope.kind === "machineAgent") {
|
|
121
|
+
const { actorAgentId, operation } = Schema.decodeUnknownSync(machineAgentRequestSchema, {
|
|
122
|
+
onExcessProperty: "error",
|
|
123
|
+
})(envelope.event);
|
|
124
|
+
if (operation.type === "createSession") {
|
|
125
|
+
const sessionId = await admitted(owner, () => owner.createSession(operation.requestId));
|
|
126
|
+
return { status: "accepted", receipt: { sessionId } };
|
|
127
|
+
}
|
|
128
|
+
const facts = await admitted(owner, () => owner.readSettlement(operation.sessionId, operation.commandId));
|
|
129
|
+
if (facts !== undefined && facts.acceptedSender !== actorAgentId)
|
|
130
|
+
return "refused";
|
|
131
|
+
return {
|
|
132
|
+
status: "accepted",
|
|
133
|
+
receipt: {
|
|
134
|
+
settlement: facts === undefined
|
|
135
|
+
? null
|
|
136
|
+
: Schema.encodeSync(Schema.fromJsonString(commandSettlementSchema))(facts),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const mail = Schema.decodeUnknownSync(BirdDogMailSchema, {
|
|
141
|
+
onExcessProperty: "error",
|
|
142
|
+
})(envelope.event);
|
|
143
|
+
if (envelope.kind !== "agentMail" ||
|
|
144
|
+
envelope.key !== mail.key ||
|
|
145
|
+
mail.to.agentId !== address.agentId ||
|
|
146
|
+
new TextEncoder().encode(JSON.stringify(mail)).byteLength > 256 * 1024)
|
|
147
|
+
return "refused";
|
|
148
|
+
// Only select the physical store here. Never rewrite frozen addresses
|
|
149
|
+
// or bypass that runtime's own recipient/sender admission.
|
|
150
|
+
const receipt = await admitted(owner, () => owner.acceptMail(mail));
|
|
151
|
+
return { status: "accepted", receipt };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
release();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
let configuration;
|
|
159
|
+
let reloading;
|
|
160
|
+
const pendingReload = () => reloading;
|
|
161
|
+
async function loadConfiguration(captured, invocationRoot, storeDirectory) {
|
|
162
|
+
const installed = await captured.load(storeDirectory, invocationRoot);
|
|
163
|
+
if (installed.diagnostics.length > 0)
|
|
164
|
+
throw new Error(installed.diagnostics
|
|
165
|
+
.map(({ extensionId, error }) => `${extensionId}: ${error.message}`)
|
|
166
|
+
.join("\n"));
|
|
167
|
+
const selected = captured.mcp.has(invocationRoot)
|
|
168
|
+
? captured.mcp.get(invocationRoot)
|
|
169
|
+
: await readProjectFungiMcpSelection(invocationRoot);
|
|
170
|
+
return {
|
|
171
|
+
installed,
|
|
172
|
+
fungiMcp: selected === undefined
|
|
173
|
+
? undefined
|
|
174
|
+
: readFungiMcpSelection(selected, {
|
|
175
|
+
credentialPath: configRoot.credentialPath,
|
|
176
|
+
}),
|
|
177
|
+
clientPath: captured.clientPath,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function reload(invocationRoot, clientPath) {
|
|
181
|
+
if (reloading !== undefined)
|
|
182
|
+
return Promise.reject(new Error("A configuration reload is already in progress"));
|
|
183
|
+
const operation = (async () => {
|
|
184
|
+
ensureRunning();
|
|
185
|
+
const path = parsePrivateLocalExecPath(clientPath);
|
|
186
|
+
await Promise.all(pendingOpenings);
|
|
187
|
+
const projects = new Set([
|
|
188
|
+
...roots.values(),
|
|
189
|
+
await realpath(invocationRoot),
|
|
190
|
+
]);
|
|
191
|
+
const selections = new Map();
|
|
192
|
+
for (const project of projects)
|
|
193
|
+
selections.set(project, await readProjectFungiMcpSelection(project));
|
|
194
|
+
const candidate = {
|
|
195
|
+
load: await localExtensions({ home: configRoot.home }).captureAgent(),
|
|
196
|
+
mcp: selections,
|
|
197
|
+
clientPath: path,
|
|
198
|
+
};
|
|
199
|
+
const prepared = [];
|
|
200
|
+
const releases = [];
|
|
201
|
+
try {
|
|
202
|
+
for (const pending of new Set(owners.values())) {
|
|
203
|
+
const { runtime } = await pending;
|
|
204
|
+
releases.push(await gateFor(runtime.databasePath).lease());
|
|
205
|
+
const root = roots.get(runtime.databasePath);
|
|
206
|
+
if (root === undefined)
|
|
207
|
+
throw new Error("Agent configuration root is unavailable");
|
|
208
|
+
prepared.push(await runtime.prepareReload(await loadConfiguration(candidate, root, dirname(runtime.databasePath))));
|
|
209
|
+
}
|
|
210
|
+
ensureRunning();
|
|
211
|
+
for (const staged of prepared)
|
|
212
|
+
staged.commit();
|
|
213
|
+
configuration = candidate;
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
const discarded = await Promise.allSettled(prepared.map((staged) => staged.discard()));
|
|
217
|
+
const failures = [];
|
|
218
|
+
for (const result of discarded)
|
|
219
|
+
if (result.status === "rejected")
|
|
220
|
+
failures.push(result.reason);
|
|
221
|
+
if (failures.length > 0)
|
|
222
|
+
throw new AggregateError([cause, ...failures], "Configuration reload failed; candidate cleanup also failed", { cause });
|
|
223
|
+
throw cause;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
for (const release of releases)
|
|
227
|
+
release();
|
|
228
|
+
// A reload's leases end here; an unused gate must not pin its Agent.
|
|
229
|
+
for (const databasePath of gates.keys())
|
|
230
|
+
pruneGate(databasePath);
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
reloading = operation;
|
|
234
|
+
return operation.finally(() => {
|
|
235
|
+
reloading = undefined;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const gates = new Map();
|
|
239
|
+
const opening = new Map();
|
|
33
240
|
const pendingOpenings = new Set();
|
|
34
241
|
let stopping = false;
|
|
35
242
|
let closing;
|
|
243
|
+
let restoring;
|
|
244
|
+
/** Requests, streams and operations currently using each open store. */
|
|
245
|
+
const uses = new Map();
|
|
246
|
+
/** Live event responses are observers, not work that survives orderly stop. */
|
|
247
|
+
const eventResponses = new Set();
|
|
248
|
+
/** Stores found idle by the previous sweep. */
|
|
249
|
+
const idleCandidates = new Set();
|
|
250
|
+
/** Released stores whose runtime is still closing; a reopen waits for it. */
|
|
251
|
+
const releasing = new Map();
|
|
252
|
+
const released = new WeakSet();
|
|
253
|
+
const openedByStore = new Map();
|
|
254
|
+
const routesByRuntime = new WeakMap();
|
|
255
|
+
let sweeping;
|
|
256
|
+
const sweepTimer = setInterval(() => {
|
|
257
|
+
void sweepIdle().catch((cause) => {
|
|
258
|
+
logEvent("service-owner.sweep-idle", cause);
|
|
259
|
+
});
|
|
260
|
+
}, idleSweepMs);
|
|
261
|
+
sweepTimer.unref();
|
|
36
262
|
function saveRecovery() {
|
|
37
263
|
if (recoveryPath === undefined)
|
|
38
264
|
return Promise.resolve();
|
|
39
265
|
const path = recoveryPath;
|
|
40
|
-
const next = saving.then(() =>
|
|
41
|
-
|
|
42
|
-
|
|
266
|
+
const next = saving.then(async () => {
|
|
267
|
+
// A ready listener may admit a new Agent during the initial read. Merge
|
|
268
|
+
// saved references before capturing any replacement list.
|
|
269
|
+
const loaded = await loadRecovery();
|
|
270
|
+
if (loaded.kind === "disabled") {
|
|
271
|
+
logEvent("service-owner.save-recovery-disabled", loaded.error);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await writeFileAtomic(path, JSON.stringify([...recovery.values()]), {
|
|
275
|
+
mode: 0o600,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
43
278
|
// A recovery hint write must not hide a live owner or poison later saves.
|
|
44
279
|
saving = next.catch((cause) => {
|
|
45
280
|
logEvent("service-owner.save-recovery", cause);
|
|
@@ -50,12 +285,16 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
50
285
|
const opened = pending;
|
|
51
286
|
// Runtime ownership survives a failed recovery-reference write. Shutdown
|
|
52
287
|
// must still drain it before the service can release its owner lock.
|
|
53
|
-
opening.
|
|
54
|
-
void opened.
|
|
288
|
+
opening.set(opened, key);
|
|
289
|
+
void opened.then((owner) => {
|
|
290
|
+
opening.set(opened, owner.runtime.agentId);
|
|
291
|
+
openedByStore.set(owner.runtime.databasePath, opened);
|
|
292
|
+
}, (cause) => {
|
|
55
293
|
opening.delete(opened);
|
|
56
294
|
logEvent("service-owner.open-runtime", cause);
|
|
57
295
|
});
|
|
58
296
|
pending = pending.then(async (owner) => {
|
|
297
|
+
roots.set(owner.runtime.databasePath, invocationRoot);
|
|
59
298
|
if (recoveryPath !== undefined) {
|
|
60
299
|
const reference = {
|
|
61
300
|
invocationRoot,
|
|
@@ -83,18 +322,278 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
83
322
|
});
|
|
84
323
|
return pending;
|
|
85
324
|
}
|
|
325
|
+
function gateFor(databasePath) {
|
|
326
|
+
let gate = gates.get(databasePath);
|
|
327
|
+
if (gate === undefined) {
|
|
328
|
+
gate = new AdmissionGate();
|
|
329
|
+
gates.set(databasePath, gate);
|
|
330
|
+
}
|
|
331
|
+
return gate;
|
|
332
|
+
}
|
|
333
|
+
/** Admit one piece of work to an Agent, refusing it if the Agent is archived by then. */
|
|
334
|
+
async function admitted(owner, admit) {
|
|
335
|
+
const release = await gateFor(owner.databasePath).lease();
|
|
336
|
+
try {
|
|
337
|
+
if (isArchivedStore(owner.databasePath))
|
|
338
|
+
throw new AgentArchiveRefused("archived", `Agent ${owner.agentId} is archived; unarchive it to work with it`);
|
|
339
|
+
return await admit();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
release();
|
|
343
|
+
pruneGate(owner.databasePath);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** The gate of an Agent whose last admission just ended is forgotten. */
|
|
347
|
+
function pruneGate(databasePath) {
|
|
348
|
+
if (gates.get(databasePath)?.idle === true)
|
|
349
|
+
gates.delete(databasePath);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* One Session as a client may use it: work starts (a prompt, a compaction, a
|
|
353
|
+
* send) only through an admission that finds the Agent in use.
|
|
354
|
+
*/
|
|
355
|
+
function gatedSession(owner, sessionId, attachment) {
|
|
356
|
+
const session = owner.getSession(sessionId, attachment);
|
|
357
|
+
function command(input) {
|
|
358
|
+
return input.type === "prompt" || input.type === "compact"
|
|
359
|
+
? admitted(owner, () => session.command(input))
|
|
360
|
+
: session.command(input);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
...session,
|
|
364
|
+
command,
|
|
365
|
+
send: (...args) => admitted(owner, () => session.send(...args)),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
86
368
|
function ensureRunning() {
|
|
87
369
|
if (stopping)
|
|
88
370
|
throw new Error("Local service is stopping");
|
|
89
371
|
}
|
|
90
|
-
|
|
372
|
+
/** Count one use of an open store until its release; any use resets idleness. */
|
|
373
|
+
function hold(databasePath) {
|
|
374
|
+
uses.set(databasePath, (uses.get(databasePath) ?? 0) + 1);
|
|
375
|
+
idleCandidates.delete(databasePath);
|
|
376
|
+
let held = true;
|
|
377
|
+
return () => {
|
|
378
|
+
if (!held)
|
|
379
|
+
return;
|
|
380
|
+
held = false;
|
|
381
|
+
const remaining = (uses.get(databasePath) ?? 1) - 1;
|
|
382
|
+
if (remaining > 0)
|
|
383
|
+
uses.set(databasePath, remaining);
|
|
384
|
+
else
|
|
385
|
+
uses.delete(databasePath);
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** An open runtime held for one use; a store released meanwhile is reopened. */
|
|
389
|
+
async function acquire(selection) {
|
|
390
|
+
for (;;) {
|
|
391
|
+
const runtime = await ownerFor(selection);
|
|
392
|
+
if (!released.has(runtime))
|
|
393
|
+
return { runtime, release: hold(runtime.databasePath) };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function routesFor(owner, attachment) {
|
|
397
|
+
const createRoutes = () => createShiitakeHttpRoutes((id) => gatedSession(owner, id, attachment), owner.listSessions, (requestId) => admitted(owner, () => owner.createSession(requestId)), owner.deleteSession, () => owner.extensions);
|
|
398
|
+
if (attachment !== undefined)
|
|
399
|
+
return createRoutes();
|
|
400
|
+
let routes = routesByRuntime.get(owner);
|
|
401
|
+
if (routes === undefined) {
|
|
402
|
+
routes = createRoutes();
|
|
403
|
+
routesByRuntime.set(owner, routes);
|
|
404
|
+
}
|
|
405
|
+
return routes;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Serve one client request on an Agent's mounted route, reopening a released
|
|
409
|
+
* Agent. A streamed body (a Session watch) keeps the Agent in use until it
|
|
410
|
+
* ends or the client disconnects.
|
|
411
|
+
*/
|
|
412
|
+
async function serve(selection, request, attachment) {
|
|
413
|
+
const { runtime, release } = await acquire(selection);
|
|
414
|
+
const streamAbort = new AbortController();
|
|
415
|
+
let response;
|
|
416
|
+
try {
|
|
417
|
+
response = await routesFor(runtime, attachment).fetch(new Request(request, {
|
|
418
|
+
signal: AbortSignal.any([request.signal, streamAbort.signal]),
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
catch (cause) {
|
|
422
|
+
release();
|
|
423
|
+
throw cause;
|
|
424
|
+
}
|
|
425
|
+
const body = response.body;
|
|
426
|
+
if (body === null) {
|
|
427
|
+
release();
|
|
428
|
+
return response;
|
|
429
|
+
}
|
|
430
|
+
const reader = body.getReader();
|
|
431
|
+
let finish = release;
|
|
432
|
+
let cancelling;
|
|
433
|
+
const cancelBody = (reason) => {
|
|
434
|
+
cancelling ??= reader.cancel(reason).finally(() => {
|
|
435
|
+
finish();
|
|
436
|
+
});
|
|
437
|
+
return cancelling;
|
|
438
|
+
};
|
|
439
|
+
if (response.headers.get("content-type")?.startsWith("text/event-stream")) {
|
|
440
|
+
const stop = () => {
|
|
441
|
+
streamAbort.abort();
|
|
442
|
+
// Hono on current Bun observes response cancellation, not just the
|
|
443
|
+
// Request signal. Join cancellation before releasing this stream's use.
|
|
444
|
+
return cancelBody(streamAbort.signal.reason);
|
|
445
|
+
};
|
|
446
|
+
finish = () => {
|
|
447
|
+
eventResponses.delete(stop);
|
|
448
|
+
release();
|
|
449
|
+
};
|
|
450
|
+
eventResponses.add(stop);
|
|
451
|
+
// An already-admitted route may finish opening its response during stop.
|
|
452
|
+
if (stopping)
|
|
453
|
+
await stop();
|
|
454
|
+
}
|
|
455
|
+
return new Response(new ReadableStream({
|
|
456
|
+
async pull(controller) {
|
|
457
|
+
try {
|
|
458
|
+
const chunk = await reader.read();
|
|
459
|
+
if (chunk.done) {
|
|
460
|
+
finish();
|
|
461
|
+
controller.close();
|
|
462
|
+
}
|
|
463
|
+
else if (chunk.value instanceof Uint8Array)
|
|
464
|
+
controller.enqueue(chunk.value);
|
|
465
|
+
else
|
|
466
|
+
throw new TypeError("A Local route streamed a non-byte chunk");
|
|
467
|
+
}
|
|
468
|
+
catch (cause) {
|
|
469
|
+
finish();
|
|
470
|
+
controller.error(cause);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
cancel(reason) {
|
|
474
|
+
return cancelBody(reason);
|
|
475
|
+
},
|
|
476
|
+
}), response);
|
|
477
|
+
}
|
|
478
|
+
async function effectiveModel(selection, sessionId, signal) {
|
|
479
|
+
const { runtime, release } = await acquire(selection);
|
|
480
|
+
try {
|
|
481
|
+
return await runtime.effectiveModel(sessionId, signal);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
release();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function sandboxStatus(selection, signal) {
|
|
488
|
+
signal.throwIfAborted();
|
|
489
|
+
const { runtime, release } = await acquire(selection);
|
|
490
|
+
try {
|
|
491
|
+
signal.throwIfAborted();
|
|
492
|
+
return runtime.sandboxStatus();
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
release();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Close Agents nothing uses that hold no work a reopen could not resume,
|
|
500
|
+
* on their second consecutive idle check, and forget them for restart.
|
|
501
|
+
*/
|
|
502
|
+
function sweepIdle() {
|
|
503
|
+
sweeping ??= (async () => {
|
|
504
|
+
const closed = [];
|
|
505
|
+
const stores = new Map();
|
|
506
|
+
for (const [key, pending] of owners)
|
|
507
|
+
if (key.startsWith("store:"))
|
|
508
|
+
stores.set(key.slice(6), pending);
|
|
509
|
+
for (const databasePath of idleCandidates)
|
|
510
|
+
if (!stores.has(databasePath))
|
|
511
|
+
idleCandidates.delete(databasePath);
|
|
512
|
+
for (const [databasePath, pending] of stores) {
|
|
513
|
+
// A configuration reload holds every open Agent it snapshotted until
|
|
514
|
+
// it commits; releasing one beneath it would strand that reload.
|
|
515
|
+
const busy = () => stopping ||
|
|
516
|
+
reloading !== undefined ||
|
|
517
|
+
uses.has(databasePath) ||
|
|
518
|
+
gates.has(databasePath) ||
|
|
519
|
+
owners.get(`store:${databasePath}`) !== pending;
|
|
520
|
+
if (busy()) {
|
|
521
|
+
idleCandidates.delete(databasePath);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
let runtime;
|
|
525
|
+
let idle;
|
|
526
|
+
try {
|
|
527
|
+
({ runtime } = await pending);
|
|
528
|
+
idle = await runtime.idle();
|
|
529
|
+
}
|
|
530
|
+
catch (cause) {
|
|
531
|
+
// One unreadable Agent stays open; the sweep still checks the rest.
|
|
532
|
+
logEvent("service-owner.read-idle", cause);
|
|
533
|
+
idleCandidates.delete(databasePath);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// Anything that started while idleness was read keeps the Agent open.
|
|
537
|
+
if (!idle || busy()) {
|
|
538
|
+
idleCandidates.delete(databasePath);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!idleCandidates.has(databasePath)) {
|
|
542
|
+
idleCandidates.add(databasePath);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
closed.push(releaseStore(databasePath, pending, runtime));
|
|
546
|
+
}
|
|
547
|
+
await Promise.all(closed);
|
|
548
|
+
})().finally(() => {
|
|
549
|
+
sweeping = undefined;
|
|
550
|
+
});
|
|
551
|
+
return sweeping;
|
|
552
|
+
}
|
|
553
|
+
function releaseStore(databasePath, pending, runtime) {
|
|
554
|
+
idleCandidates.delete(databasePath);
|
|
555
|
+
released.add(runtime);
|
|
556
|
+
for (const [key, value] of owners)
|
|
557
|
+
if (value === pending)
|
|
558
|
+
owners.delete(key);
|
|
559
|
+
emptySessions.delete(databasePath);
|
|
560
|
+
roots.delete(databasePath);
|
|
561
|
+
recovery.delete(databasePath);
|
|
562
|
+
const opened = openedByStore.get(databasePath);
|
|
563
|
+
openedByStore.delete(databasePath);
|
|
564
|
+
if (opened !== undefined)
|
|
565
|
+
opening.delete(opened);
|
|
566
|
+
const closed = (async () => {
|
|
567
|
+
try {
|
|
568
|
+
await runtime.close();
|
|
569
|
+
}
|
|
570
|
+
catch (cause) {
|
|
571
|
+
logEvent("service-owner.release-runtime", cause);
|
|
572
|
+
}
|
|
573
|
+
await saveRecovery();
|
|
574
|
+
})();
|
|
575
|
+
releasing.set(databasePath, closed);
|
|
576
|
+
return closed.finally(() => {
|
|
577
|
+
if (releasing.get(databasePath) === closed)
|
|
578
|
+
releasing.delete(databasePath);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/** Settle every pending release of this store, then continue synchronously. */
|
|
582
|
+
async function releasedClosed(databasePath) {
|
|
583
|
+
for (let closed = releasing.get(databasePath); closed !== undefined; closed = releasing.get(databasePath)) {
|
|
584
|
+
await closed;
|
|
585
|
+
ensureRunning();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function selected(owner, sessionId, invocationRoot, release) {
|
|
91
589
|
return {
|
|
92
590
|
agentId: owner.agentId,
|
|
93
591
|
databasePath: owner.databasePath,
|
|
592
|
+
invocationRoot,
|
|
94
593
|
sessionId,
|
|
95
|
-
session: owner
|
|
96
|
-
|
|
97
|
-
|
|
594
|
+
session: gatedSession(owner, sessionId),
|
|
595
|
+
/** End this selection's use; the mounted route reopens the Agent later. */
|
|
596
|
+
release,
|
|
98
597
|
};
|
|
99
598
|
}
|
|
100
599
|
async function createSession(creation) {
|
|
@@ -108,57 +607,97 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
108
607
|
const databasePath = requested === undefined
|
|
109
608
|
? undefined
|
|
110
609
|
: await realpath(resolve(invocationRoot, requested));
|
|
111
|
-
if (databasePath !== undefined
|
|
610
|
+
if (databasePath !== undefined)
|
|
112
611
|
return databasePath;
|
|
612
|
+
// Name the project's open Agent by its store, so a reopen after an idle
|
|
613
|
+
// release waits for that close like any other store selection.
|
|
614
|
+
const projectOwner = owners.get(`project:${invocationRoot}`);
|
|
615
|
+
if (projectOwner !== undefined)
|
|
616
|
+
return (await projectOwner).runtime.databasePath;
|
|
113
617
|
const stores = await listPrivateLocalAgentStores(invocationRoot);
|
|
114
618
|
ensureRunning();
|
|
115
619
|
if (stores.length > 1)
|
|
116
|
-
throw new
|
|
620
|
+
throw new LocalAgentSelectionAmbiguous(stores.map((store) => basename(dirname(store))));
|
|
117
621
|
return stores[0];
|
|
118
622
|
}
|
|
119
623
|
async function ownerFor(selection) {
|
|
624
|
+
while (pendingReload() !== undefined)
|
|
625
|
+
await pendingReload();
|
|
120
626
|
ensureRunning();
|
|
121
627
|
const invocationRoot = await realpath(selection.invocationRoot);
|
|
122
628
|
const databasePath = await selectedDatabasePath(invocationRoot, selection.databasePath);
|
|
123
629
|
ensureRunning();
|
|
124
630
|
const fungiMcp = selection.fungiMcp === undefined
|
|
125
631
|
? undefined
|
|
126
|
-
: readFungiMcpSelection(selection.fungiMcp
|
|
632
|
+
: readFungiMcpSelection(selection.fungiMcp, {
|
|
633
|
+
credentialPath: configRoot.credentialPath,
|
|
634
|
+
});
|
|
127
635
|
ensureRunning();
|
|
128
636
|
// A new project selection shares its in-flight owner; explicit resume keys the store.
|
|
129
637
|
const key = databasePath === undefined
|
|
130
638
|
? `project:${invocationRoot}`
|
|
131
639
|
: `store:${databasePath}`;
|
|
640
|
+
while (pendingReload() !== undefined)
|
|
641
|
+
await pendingReload();
|
|
642
|
+
ensureRunning();
|
|
643
|
+
// One process owns a store once: a reopen waits for its released close.
|
|
644
|
+
if (databasePath !== undefined)
|
|
645
|
+
await releasedClosed(databasePath);
|
|
132
646
|
let pending = owners.get(key);
|
|
133
647
|
if (pending === undefined && pendingOpenings.size > 0) {
|
|
134
648
|
// A fresh project does not reveal its canonical store path until opening.
|
|
135
649
|
// Let current openings install both project and store aliases, then recheck.
|
|
136
650
|
await Promise.allSettled(pendingOpenings);
|
|
651
|
+
while (pendingReload() !== undefined)
|
|
652
|
+
await pendingReload();
|
|
137
653
|
ensureRunning();
|
|
138
654
|
pending = owners.get(key);
|
|
139
655
|
}
|
|
656
|
+
const openingHere = pending === undefined;
|
|
140
657
|
if (pending === undefined) {
|
|
141
658
|
const projectControl = databasePath === undefined ||
|
|
142
659
|
belongsToProject(invocationRoot, databasePath);
|
|
143
660
|
let options = {
|
|
661
|
+
configRoot,
|
|
144
662
|
invocationRoot,
|
|
145
663
|
databasePath,
|
|
664
|
+
// The host must persist/admit this BEFORE Shiitake can recover work.
|
|
665
|
+
requireSandbox: selection.requireSandbox,
|
|
146
666
|
agentControl: projectControl ? directory(invocationRoot) : undefined,
|
|
147
667
|
mail: projectControl ? mailRouting(invocationRoot) : undefined,
|
|
668
|
+
meshAdmission,
|
|
148
669
|
};
|
|
149
670
|
if (fungiMcp !== undefined)
|
|
150
671
|
options = { ...options, fungiMcp };
|
|
672
|
+
const captured = configuration;
|
|
673
|
+
if (captured !== undefined)
|
|
674
|
+
options = {
|
|
675
|
+
...options,
|
|
676
|
+
configuration: (store) => loadConfiguration(captured, invocationRoot, store),
|
|
677
|
+
};
|
|
151
678
|
pending = trackOpening(openRuntime(options).then((runtime) => ({ runtime, fungiMcp })), invocationRoot, key);
|
|
152
679
|
}
|
|
153
680
|
const owner = await pending;
|
|
154
681
|
ensureRunning();
|
|
155
|
-
if (
|
|
682
|
+
if (configuration === undefined &&
|
|
683
|
+
fungiMcp !== undefined &&
|
|
156
684
|
!sameFungiMcpConnection(owner.fungiMcp, fungiMcp))
|
|
157
685
|
throw new FungiMcpSelectionConflict();
|
|
686
|
+
if (!openingHere && selection.requireSandbox !== undefined)
|
|
687
|
+
owner.runtime.setRequireSandbox(selection.requireSandbox);
|
|
158
688
|
return owner.runtime;
|
|
159
689
|
}
|
|
160
690
|
async function select(selection, createRequestId) {
|
|
161
|
-
const owner = await
|
|
691
|
+
const { runtime: owner, release } = await acquire(selection);
|
|
692
|
+
try {
|
|
693
|
+
return selected(owner, await selectedSession(owner, selection, createRequestId), await realpath(selection.invocationRoot), release);
|
|
694
|
+
}
|
|
695
|
+
catch (cause) {
|
|
696
|
+
release();
|
|
697
|
+
throw cause;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async function selectedSession(owner, selection, createRequestId) {
|
|
162
701
|
let sessionId = selection.sessionId;
|
|
163
702
|
if (createRequestId !== undefined) {
|
|
164
703
|
sessionId = await owner.createSession(createRequestId);
|
|
@@ -180,7 +719,7 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
180
719
|
if (selection.clientPath !== undefined &&
|
|
181
720
|
!owner.bindSessionPath(sessionId, selection.clientPath))
|
|
182
721
|
throw new LocalSessionPathConflict();
|
|
183
|
-
return
|
|
722
|
+
return sessionId;
|
|
184
723
|
}
|
|
185
724
|
/**
|
|
186
725
|
* Project mail routing for every Agent this service owns in one project.
|
|
@@ -190,15 +729,54 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
190
729
|
function mailRouting(invocationRoot) {
|
|
191
730
|
const control = directory(invocationRoot);
|
|
192
731
|
return {
|
|
193
|
-
deliver: async (mail) =>
|
|
194
|
-
|
|
732
|
+
deliver: async (mail) => {
|
|
733
|
+
const remote = [...meshDirectories.values()].find(({ record }) => record.invocationRoot === invocationRoot &&
|
|
734
|
+
record.bindings.some((binding) => binding.agentId === mail.from.agentId));
|
|
735
|
+
if (remote !== undefined) {
|
|
736
|
+
if (!remote.agents.some((agent) => agent.agentId === mail.to.agentId))
|
|
737
|
+
throw new Error("Mesh mail recipient is outside this Team");
|
|
738
|
+
const result = await mesh.deliver(remote.record, {
|
|
739
|
+
key: mail.key,
|
|
740
|
+
kind: "agentMail",
|
|
741
|
+
address: {
|
|
742
|
+
scopeId: remote.record.scopeId,
|
|
743
|
+
agentId: mail.to.agentId,
|
|
744
|
+
},
|
|
745
|
+
event: mail,
|
|
746
|
+
});
|
|
747
|
+
if (result === "accepted" ||
|
|
748
|
+
result === "duplicate" ||
|
|
749
|
+
result === "refused" ||
|
|
750
|
+
!("receipt" in result) ||
|
|
751
|
+
result.status === "refused")
|
|
752
|
+
throw new Error("Mesh recipient did not return command acceptance");
|
|
753
|
+
return parseAccepted(JSON.stringify(result.receipt));
|
|
754
|
+
}
|
|
755
|
+
const { owner, release } = await control.owner(mail.to.agentId, new AbortController().signal);
|
|
756
|
+
try {
|
|
757
|
+
return await admitted(owner, () => owner.acceptMail(mail));
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
release();
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
// A settled command stays readable once its Agent is archived.
|
|
764
|
+
readSettlement: async (agentId, sessionId, commandId) => {
|
|
765
|
+
const { owner, release } = await control.owner(agentId, new AbortController().signal, true);
|
|
766
|
+
try {
|
|
767
|
+
return await owner.readSettlement(sessionId, commandId);
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
release();
|
|
771
|
+
}
|
|
772
|
+
},
|
|
195
773
|
};
|
|
196
774
|
}
|
|
197
775
|
function directory(invocationRoot) {
|
|
198
|
-
async function targets(signal) {
|
|
776
|
+
async function targets(signal, visibility = "active") {
|
|
199
777
|
ensureRunning();
|
|
200
778
|
signal.throwIfAborted();
|
|
201
|
-
const paths = await listPrivateLocalAgentStores(invocationRoot);
|
|
779
|
+
const paths = await listPrivateLocalAgentStores(invocationRoot, visibility);
|
|
202
780
|
signal.throwIfAborted();
|
|
203
781
|
const result = new Map();
|
|
204
782
|
for (const databasePath of paths) {
|
|
@@ -213,38 +791,187 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
213
791
|
}
|
|
214
792
|
return result;
|
|
215
793
|
}
|
|
216
|
-
async function admittedOwner(agentId, signal) {
|
|
217
|
-
const
|
|
794
|
+
async function admittedOwner(agentId, signal, includeArchived = false) {
|
|
795
|
+
const visibility = includeArchived ? "all" : "active";
|
|
796
|
+
const databasePath = (await targets(signal, visibility)).get(agentId);
|
|
797
|
+
if (databasePath === undefined)
|
|
798
|
+
return outsideOrArchived(agentId, signal);
|
|
799
|
+
const { runtime, release } = await acquire({
|
|
800
|
+
invocationRoot,
|
|
801
|
+
databasePath,
|
|
802
|
+
});
|
|
803
|
+
try {
|
|
804
|
+
// Archived while the owner opened: the Agent is no longer in use.
|
|
805
|
+
if ((await targets(signal, visibility)).get(agentId) !== databasePath)
|
|
806
|
+
return await outsideOrArchived(agentId, signal);
|
|
807
|
+
signal.throwIfAborted();
|
|
808
|
+
ensureRunning();
|
|
809
|
+
return { owner: runtime, release };
|
|
810
|
+
}
|
|
811
|
+
catch (cause) {
|
|
812
|
+
release();
|
|
813
|
+
throw cause;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function outsideOrArchived(agentId, signal) {
|
|
817
|
+
if ((await targets(signal, "archived")).has(agentId))
|
|
818
|
+
throw new AgentArchiveRefused("archived", `Agent ${agentId} is archived; unarchive it to work with it`);
|
|
819
|
+
throw new Error("Agent is outside this Local project");
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The first command still queued or running on any of an Agent's Sessions,
|
|
823
|
+
* so an archive can name it rather than cancel it.
|
|
824
|
+
*/
|
|
825
|
+
async function runningCommand(owner) {
|
|
826
|
+
let after;
|
|
827
|
+
do {
|
|
828
|
+
const page = await owner.listSessions(after);
|
|
829
|
+
for (const row of page.sessions) {
|
|
830
|
+
if (row.state !== "running" && row.state !== "needs_you")
|
|
831
|
+
continue;
|
|
832
|
+
const { activity } = await owner
|
|
833
|
+
.getSession(row.sessionId)
|
|
834
|
+
.read({ limit: 1 });
|
|
835
|
+
if (activity.queue.state !== "idle")
|
|
836
|
+
return {
|
|
837
|
+
sessionId: row.sessionId,
|
|
838
|
+
commandId: activity.queue.commandId,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
after = page.next;
|
|
842
|
+
} while (after !== undefined);
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Archive or restore one Agent by flipping the flag its store owns. A
|
|
847
|
+
* person may do it to any Agent; an Agent only to one it created, never
|
|
848
|
+
* itself. Archiving never cancels work, so it refuses while a command runs.
|
|
849
|
+
*/
|
|
850
|
+
async function setArchived({ agentId, by }, archived, signal) {
|
|
851
|
+
const databasePath = (await targets(signal, "all")).get(agentId);
|
|
218
852
|
if (databasePath === undefined)
|
|
219
853
|
throw new Error("Agent is outside this Local project");
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
854
|
+
let createdBy;
|
|
855
|
+
try {
|
|
856
|
+
({ createdBy } = readPrivateLocalAgentProfile(databasePath));
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
if (!(error instanceof LocalStoreSchemaError))
|
|
860
|
+
throw error;
|
|
861
|
+
throw new AgentArchiveRefused("unreadable", `Agent ${agentId}'s store ${unreadableStoreReason(agentId)}`);
|
|
862
|
+
}
|
|
863
|
+
if (by.kind === "agent") {
|
|
864
|
+
if (by.agentId === agentId)
|
|
865
|
+
throw new AgentArchiveRefused("self", "an Agent cannot archive or restore itself");
|
|
866
|
+
if (createdBy !== by.agentId)
|
|
867
|
+
throw new AgentArchiveRefused("not_creator", "an Agent can archive or restore only an Agent it created");
|
|
868
|
+
}
|
|
869
|
+
const { runtime: owner, release } = await acquire({
|
|
870
|
+
invocationRoot,
|
|
871
|
+
databasePath,
|
|
872
|
+
});
|
|
873
|
+
// Alone with the Agent: no work is admitted between the checks and the flag.
|
|
874
|
+
try {
|
|
875
|
+
await gateFor(databasePath).exclusive(async () => {
|
|
876
|
+
if (archived) {
|
|
877
|
+
const running = await runningCommand(owner);
|
|
878
|
+
if (running !== undefined)
|
|
879
|
+
throw new AgentArchiveRefused("running", `command ${running.commandId} is running in Session ${running.sessionId}; cancel it first`);
|
|
880
|
+
const waiting = await owner.pendingDelegation();
|
|
881
|
+
if (waiting !== undefined)
|
|
882
|
+
throw new AgentArchiveRefused("pending", waiting.unreadable === undefined
|
|
883
|
+
? `it still waits for command ${waiting.commandId} it delegated to Agent ${waiting.agentId} (Session ${waiting.sessionId}); wait for it or cancel it first`
|
|
884
|
+
: `it delegated command ${waiting.commandId} to Agent ${waiting.agentId} (Session ${waiting.sessionId}) and that work cannot be read (${waiting.unreadable}); wait for it or cancel it first`);
|
|
885
|
+
}
|
|
886
|
+
signal.throwIfAborted();
|
|
887
|
+
owner.setArchived(archived);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
release();
|
|
892
|
+
pruneGate(databasePath);
|
|
893
|
+
}
|
|
226
894
|
}
|
|
227
895
|
return {
|
|
228
896
|
owner: admittedOwner,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
897
|
+
archive: (request, signal) => setArchived(request, true, signal),
|
|
898
|
+
unarchive: (request, signal) => setArchived(request, false, signal),
|
|
899
|
+
list: async (signal, options) => {
|
|
900
|
+
const stores = await targets(signal, options?.archived === true ? "archived" : "active");
|
|
901
|
+
return [...stores].map(([agentId, databasePath]) => {
|
|
902
|
+
let profile;
|
|
903
|
+
try {
|
|
904
|
+
profile = readPrivateLocalAgentProfile(databasePath);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
// One store written before the current schema fails its own row,
|
|
908
|
+
// loudly; the other Agents still list.
|
|
909
|
+
if (!(error instanceof LocalStoreSchemaError))
|
|
910
|
+
throw error;
|
|
911
|
+
logEvent("list-agent-store", error);
|
|
912
|
+
return {
|
|
913
|
+
agentId,
|
|
914
|
+
name: agentId,
|
|
915
|
+
workingDirectory: invocationRoot,
|
|
916
|
+
unreadable: unreadableStoreReason(agentId),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const { name, model, createdBy } = profile;
|
|
920
|
+
let agent = {
|
|
921
|
+
agentId,
|
|
922
|
+
name: name ?? agentId,
|
|
923
|
+
workingDirectory: invocationRoot,
|
|
924
|
+
};
|
|
925
|
+
agent = { ...agent, createdBy };
|
|
926
|
+
return model === null ? agent : { ...agent, model };
|
|
927
|
+
});
|
|
928
|
+
},
|
|
929
|
+
create: async ({ requestId, name, model, extensions: requested = [], createdBy }, signal) => {
|
|
234
930
|
ensureRunning();
|
|
235
931
|
signal.throwIfAborted();
|
|
932
|
+
// An Agent creating an Agent cannot give it more than it holds. Refuse
|
|
933
|
+
// before anything exists; never trim the request.
|
|
934
|
+
const creatorDatabase = (await targets(signal)).get(createdBy);
|
|
935
|
+
if (creatorDatabase === undefined)
|
|
936
|
+
throw new AgentCreateRefused("no_creator", "the creating Agent is not in this Local project");
|
|
937
|
+
const creatorStore = dirname(creatorDatabase);
|
|
938
|
+
const sandbox = readPrivateLocalAgentProfile(creatorDatabase);
|
|
939
|
+
try {
|
|
940
|
+
await extensions.assertHeldByAgent(creatorStore, requested);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
if (error instanceof LocalExtensionError &&
|
|
944
|
+
error.reason === "not-held")
|
|
945
|
+
throw new AgentCreateRefused("exceeds_creator", error.message);
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
236
948
|
const key = `create:${JSON.stringify([invocationRoot, requestId])}`;
|
|
237
949
|
const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId);
|
|
238
950
|
signal.throwIfAborted();
|
|
239
951
|
ensureRunning();
|
|
240
952
|
const databasePath = createStore.databasePath;
|
|
953
|
+
for (const id of requested)
|
|
954
|
+
await extensions.enableAgent(dirname(databasePath), id, {
|
|
955
|
+
agentStoreDirectory: creatorStore,
|
|
956
|
+
});
|
|
957
|
+
signal.throwIfAborted();
|
|
958
|
+
while (pendingReload() !== undefined)
|
|
959
|
+
await pendingReload();
|
|
960
|
+
ensureRunning();
|
|
961
|
+
await releasedClosed(databasePath);
|
|
962
|
+
ensureRunning();
|
|
241
963
|
let pending = owners.get(key);
|
|
242
964
|
if (pending === undefined) {
|
|
243
965
|
pending = owners.get(`store:${databasePath}`);
|
|
244
966
|
if (pending === undefined) {
|
|
245
967
|
let options = {
|
|
968
|
+
configRoot,
|
|
246
969
|
invocationRoot,
|
|
247
970
|
createRequestId: requestId,
|
|
971
|
+
name,
|
|
972
|
+
createdBy,
|
|
973
|
+
requireSandbox: sandbox.requireSandbox,
|
|
974
|
+
folder: sandbox.folder,
|
|
248
975
|
agentControl: directory(invocationRoot),
|
|
249
976
|
mail: mailRouting(invocationRoot),
|
|
250
977
|
};
|
|
@@ -255,6 +982,12 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
255
982
|
modelId: model.modelId,
|
|
256
983
|
reasoning: model.reasoning,
|
|
257
984
|
};
|
|
985
|
+
const captured = configuration;
|
|
986
|
+
if (captured !== undefined)
|
|
987
|
+
options = {
|
|
988
|
+
...options,
|
|
989
|
+
configuration: (store) => loadConfiguration(captured, invocationRoot, store),
|
|
990
|
+
};
|
|
258
991
|
pending = trackOpening(openRuntime(options).then((runtime) => ({
|
|
259
992
|
runtime,
|
|
260
993
|
fungiMcp: undefined,
|
|
@@ -267,27 +1000,54 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
267
1000
|
if (owner.runtime.databasePath !== databasePath)
|
|
268
1001
|
throw new Error("Local Agent create identity does not match its store");
|
|
269
1002
|
owners.set(key, pending);
|
|
270
|
-
|
|
1003
|
+
const release = hold(databasePath);
|
|
1004
|
+
try {
|
|
1005
|
+
await owner.runtime.assertModelReady("main", signal);
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
release();
|
|
1009
|
+
}
|
|
271
1010
|
const agentId = createStore.agentId;
|
|
272
|
-
|
|
1011
|
+
const created = {
|
|
1012
|
+
agentId,
|
|
1013
|
+
name: owner.runtime.name,
|
|
1014
|
+
createdBy,
|
|
1015
|
+
workingDirectory: invocationRoot,
|
|
1016
|
+
};
|
|
1017
|
+
return Object.freeze(model === undefined ? created : { ...created, model });
|
|
273
1018
|
},
|
|
274
1019
|
setModel: async ({ agentId, model }, signal) => {
|
|
275
|
-
const owner = await admittedOwner(agentId, signal);
|
|
276
|
-
|
|
1020
|
+
const { owner, release } = await admittedOwner(agentId, signal);
|
|
1021
|
+
try {
|
|
1022
|
+
await owner.setModel(model, signal);
|
|
1023
|
+
}
|
|
1024
|
+
finally {
|
|
1025
|
+
release();
|
|
1026
|
+
}
|
|
277
1027
|
},
|
|
278
1028
|
checkModel: async ({ agentId, sessionId }, signal) => {
|
|
279
|
-
const owner = await admittedOwner(agentId, signal);
|
|
280
|
-
|
|
1029
|
+
const { owner, release } = await admittedOwner(agentId, signal);
|
|
1030
|
+
try {
|
|
1031
|
+
await owner.assertModelReady(sessionId, signal);
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
release();
|
|
1035
|
+
}
|
|
281
1036
|
},
|
|
282
1037
|
open: async (agentId, signal) => {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
1038
|
+
const held = await admittedOwner(agentId, signal);
|
|
1039
|
+
const owner = held.owner;
|
|
1040
|
+
// The service retains runtime ownership; an operation holds a use of it.
|
|
1041
|
+
const release = () => {
|
|
1042
|
+
held.release();
|
|
1043
|
+
return Promise.resolve();
|
|
1044
|
+
};
|
|
286
1045
|
return {
|
|
287
1046
|
agent: {
|
|
288
|
-
|
|
1047
|
+
// Work starts only through an admission that finds the Agent in use.
|
|
1048
|
+
session: (sessionId) => gatedSession(owner, sessionId),
|
|
289
1049
|
listSessions: owner.listSessions,
|
|
290
|
-
createSession: owner.createSession,
|
|
1050
|
+
createSession: (requestId) => admitted(owner, () => owner.createSession(requestId)),
|
|
291
1051
|
deleteSession: owner.deleteSession,
|
|
292
1052
|
extensions: owner.extensions,
|
|
293
1053
|
close: release,
|
|
@@ -299,21 +1059,68 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
299
1059
|
}
|
|
300
1060
|
function close() {
|
|
301
1061
|
stopping = true;
|
|
1062
|
+
clearInterval(sweepTimer);
|
|
302
1063
|
closing ??= (async () => {
|
|
1064
|
+
const meshResults = await Promise.allSettled([mesh.close()]);
|
|
1065
|
+
// End our observers before deciding which Agents have work to resume.
|
|
1066
|
+
// Their client disconnect may not have reached the server yet.
|
|
1067
|
+
await Promise.all([...eventResponses].map((stop) => stop()));
|
|
1068
|
+
// Restoration may still be reading references or opening one Agent.
|
|
1069
|
+
// It stops scheduling at shutdown; join it before saving or closing.
|
|
1070
|
+
await restoring;
|
|
1071
|
+
// Reload owns staged activation scopes until it publishes or discards.
|
|
1072
|
+
// Its caller receives validation failures; shutdown waits for cleanup.
|
|
1073
|
+
await Promise.allSettled(reloading === undefined ? [] : [reloading]);
|
|
303
1074
|
await Promise.allSettled(pendingOpenings);
|
|
304
|
-
|
|
1075
|
+
await Promise.allSettled([
|
|
1076
|
+
...(sweeping === undefined ? [] : [sweeping]),
|
|
1077
|
+
...releasing.values(),
|
|
1078
|
+
]);
|
|
1079
|
+
// Close up to four independent stores concurrently within the listener's
|
|
1080
|
+
// single overall budget. One hung close must not block every other Agent.
|
|
1081
|
+
const settled = await Promise.allSettled([...opening.keys()].map(async (pending) => ({
|
|
1082
|
+
pending,
|
|
1083
|
+
owner: await pending,
|
|
1084
|
+
})));
|
|
305
1085
|
const failures = [];
|
|
306
|
-
for (const result of
|
|
307
|
-
if (result.status === "
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1086
|
+
for (const result of meshResults)
|
|
1087
|
+
if (result.status === "rejected")
|
|
1088
|
+
failures.push(result.reason);
|
|
1089
|
+
// The next service reopens only Agents with work to resume. Decide and
|
|
1090
|
+
// save that list before closing anything: if the listener's overall
|
|
1091
|
+
// drain budget ends a slow close, the saved list is already right.
|
|
1092
|
+
await Effect.runPromise(Effect.forEach(settled, (result) => Effect.promise(async () => {
|
|
1093
|
+
if (result.status !== "fulfilled")
|
|
1094
|
+
return;
|
|
1095
|
+
const { owner } = result.value;
|
|
1096
|
+
// A request still draining may yet commit work, so an Agent in
|
|
1097
|
+
// use or admitting work keeps its entry, before or after the read.
|
|
1098
|
+
const databasePath = owner.runtime.databasePath;
|
|
1099
|
+
const unused = () => !uses.has(databasePath) && !gates.has(databasePath);
|
|
1100
|
+
try {
|
|
1101
|
+
if (unused() && (await owner.runtime.idle()) && unused())
|
|
1102
|
+
recovery.delete(databasePath);
|
|
315
1103
|
}
|
|
316
|
-
|
|
1104
|
+
catch (cause) {
|
|
1105
|
+
logEvent("service-owner.read-idle", cause);
|
|
1106
|
+
}
|
|
1107
|
+
}), { concurrency: 4, discard: true }));
|
|
1108
|
+
await saveRecovery();
|
|
1109
|
+
await Effect.runPromise(Effect.forEach(settled, (result) => Effect.promise(async () => {
|
|
1110
|
+
if (result.status !== "fulfilled")
|
|
1111
|
+
return;
|
|
1112
|
+
const { pending, owner } = result.value;
|
|
1113
|
+
try {
|
|
1114
|
+
await owner.runtime.close();
|
|
1115
|
+
}
|
|
1116
|
+
catch (cause) {
|
|
1117
|
+
logEvent("service-owner.close-runtime", cause);
|
|
1118
|
+
failures.push(cause);
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
opening.delete(pending);
|
|
1122
|
+
}
|
|
1123
|
+
}), { concurrency: 4, discard: true }));
|
|
317
1124
|
owners.clear();
|
|
318
1125
|
opening.clear();
|
|
319
1126
|
pendingOpenings.clear();
|
|
@@ -322,24 +1129,37 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
322
1129
|
})();
|
|
323
1130
|
return closing;
|
|
324
1131
|
}
|
|
325
|
-
|
|
1132
|
+
function restore() {
|
|
1133
|
+
restoring ??= restoreSaved();
|
|
1134
|
+
return restoring;
|
|
1135
|
+
}
|
|
1136
|
+
function loadRecovery() {
|
|
1137
|
+
loadingRecovery ??= readRecovery();
|
|
1138
|
+
return loadingRecovery;
|
|
1139
|
+
}
|
|
1140
|
+
function disableRecovery(reason, cause) {
|
|
1141
|
+
const error = new LocalRecoveryPersistenceDisabled(reason, cause);
|
|
1142
|
+
onRecoveryDisabled(reason === "read" ? "read-failed" : "decode-failed");
|
|
1143
|
+
logEvent(`service-owner.recovery-disabled.${error.reason}`, error);
|
|
1144
|
+
return { kind: "disabled", error };
|
|
1145
|
+
}
|
|
1146
|
+
async function readRecovery() {
|
|
326
1147
|
if (recoveryPath === undefined)
|
|
327
|
-
return;
|
|
1148
|
+
return { kind: "loaded", references: [] };
|
|
328
1149
|
let content;
|
|
329
1150
|
try {
|
|
330
|
-
content = await
|
|
1151
|
+
content = await readRecoveryManifest(recoveryPath);
|
|
331
1152
|
}
|
|
332
1153
|
catch (cause) {
|
|
333
1154
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
334
|
-
return;
|
|
335
|
-
|
|
336
|
-
|
|
1155
|
+
return { kind: "loaded", references: [] };
|
|
1156
|
+
// Unknown contents are not an empty manifest. Keep the file untouched
|
|
1157
|
+
// for this service lifetime while still admitting on-demand Agents.
|
|
1158
|
+
return disableRecovery("read", cause);
|
|
337
1159
|
}
|
|
338
1160
|
const decoded = decodeRecoveryFile(content);
|
|
339
1161
|
if (decoded._tag === "Failure") {
|
|
340
|
-
|
|
341
|
-
await saveRecovery();
|
|
342
|
-
return;
|
|
1162
|
+
return disableRecovery("decode", decoded.failure);
|
|
343
1163
|
}
|
|
344
1164
|
const saved = [];
|
|
345
1165
|
for (const entry of decoded.success) {
|
|
@@ -349,15 +1169,29 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
349
1169
|
continue;
|
|
350
1170
|
}
|
|
351
1171
|
saved.push(selection.data);
|
|
352
|
-
|
|
1172
|
+
// A concurrent client may already have installed the current reference.
|
|
1173
|
+
if (!recovery.has(selection.data.databasePath))
|
|
1174
|
+
recovery.set(selection.data.databasePath, selection.data);
|
|
353
1175
|
}
|
|
1176
|
+
return { kind: "loaded", references: saved };
|
|
1177
|
+
}
|
|
1178
|
+
async function restoreSaved() {
|
|
1179
|
+
const loaded = await loadRecovery();
|
|
1180
|
+
if (loaded.kind === "disabled")
|
|
1181
|
+
return;
|
|
354
1182
|
// Preload valid references before opening, then isolate and prune each
|
|
355
1183
|
// failed owner so an unavailable project cannot prevent service startup.
|
|
356
|
-
for (const selection of
|
|
1184
|
+
for (const selection of loaded.references) {
|
|
1185
|
+
if (stopping)
|
|
1186
|
+
break;
|
|
357
1187
|
try {
|
|
358
1188
|
await ownerFor(selection);
|
|
359
1189
|
}
|
|
360
1190
|
catch (cause) {
|
|
1191
|
+
// A completed open remains owned and is drained by close(). Keep its
|
|
1192
|
+
// recovery reference, and all not-yet-opened references, for next time.
|
|
1193
|
+
if (closing !== undefined)
|
|
1194
|
+
break;
|
|
361
1195
|
logEvent("service-owner.restore-entry", cause);
|
|
362
1196
|
// A good owner may already have replaced another stale reference to
|
|
363
1197
|
// this store. Prune only the failing reference, never that live owner.
|
|
@@ -367,5 +1201,169 @@ export function createLocalServiceOwners(openRuntime = openPrivateLocalRuntimeHo
|
|
|
367
1201
|
}
|
|
368
1202
|
await saveRecovery();
|
|
369
1203
|
}
|
|
370
|
-
|
|
1204
|
+
/** A person archives or restores any Agent of one project folder. */
|
|
1205
|
+
async function archive(request, signal) {
|
|
1206
|
+
const control = directory(await realpath(request.invocationRoot));
|
|
1207
|
+
const target = {
|
|
1208
|
+
agentId: request.agentId,
|
|
1209
|
+
by: { kind: "human" },
|
|
1210
|
+
};
|
|
1211
|
+
await (request.archived
|
|
1212
|
+
? control.archive(target, signal)
|
|
1213
|
+
: control.unarchive(target, signal));
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* This is Agent lifetime ancestry, not turn ancestry: a stopped parent still
|
|
1217
|
+
* owns its helpers. Snapshot once, visit descendants before their creator,
|
|
1218
|
+
* and never delete or archive their conversations.
|
|
1219
|
+
*/
|
|
1220
|
+
async function stopAll(request) {
|
|
1221
|
+
ensureRunning();
|
|
1222
|
+
const invocationRoot = await realpath(request.invocationRoot);
|
|
1223
|
+
const paths = await listPrivateLocalAgentStores(invocationRoot, "all");
|
|
1224
|
+
if (paths.length > 4_096)
|
|
1225
|
+
throw new Error("Too many Agent stores to stop safely in one request; no cascade was started");
|
|
1226
|
+
const result = {
|
|
1227
|
+
rootAgentId: request.agentId,
|
|
1228
|
+
agents: [],
|
|
1229
|
+
discoveryFailures: [],
|
|
1230
|
+
};
|
|
1231
|
+
const agents = new Map();
|
|
1232
|
+
for (const databasePath of paths) {
|
|
1233
|
+
const agentId = basename(dirname(databasePath));
|
|
1234
|
+
try {
|
|
1235
|
+
if (!belongsToProject(invocationRoot, databasePath))
|
|
1236
|
+
throw new Error("Agent is outside this Local project; it was not stopped");
|
|
1237
|
+
const profile = readPrivateLocalAgentProfile(databasePath);
|
|
1238
|
+
agents.set(agentId, {
|
|
1239
|
+
databasePath,
|
|
1240
|
+
name: profile.name ?? agentId,
|
|
1241
|
+
createdBy: profile.createdBy,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
catch (cause) {
|
|
1245
|
+
result.discoveryFailures.push({
|
|
1246
|
+
agentId,
|
|
1247
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (!agents.has(request.agentId))
|
|
1252
|
+
throw new Error("The selected Agent could not be read; no cascade was started");
|
|
1253
|
+
// Iterative postorder is bounded by the discovered stores, including cycles.
|
|
1254
|
+
const seen = new Set();
|
|
1255
|
+
const stack = [{ agentId: request.agentId, visited: false }];
|
|
1256
|
+
const ordered = [];
|
|
1257
|
+
while (stack.length > 0) {
|
|
1258
|
+
const entry = stack.pop();
|
|
1259
|
+
if (entry === undefined)
|
|
1260
|
+
break;
|
|
1261
|
+
if (entry.visited) {
|
|
1262
|
+
ordered.push(entry.agentId);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (seen.has(entry.agentId))
|
|
1266
|
+
continue;
|
|
1267
|
+
seen.add(entry.agentId);
|
|
1268
|
+
stack.push({ ...entry, visited: true });
|
|
1269
|
+
const children = [...agents].filter(([, agent]) => agent.createdBy === entry.agentId);
|
|
1270
|
+
for (const [agentId] of children.reverse())
|
|
1271
|
+
stack.push({ agentId, visited: false });
|
|
1272
|
+
}
|
|
1273
|
+
for (const agentId of ordered) {
|
|
1274
|
+
const agent = agents.get(agentId);
|
|
1275
|
+
if (agent === undefined)
|
|
1276
|
+
continue;
|
|
1277
|
+
let timer;
|
|
1278
|
+
const deadline = new AbortController();
|
|
1279
|
+
try {
|
|
1280
|
+
const stopped = await Promise.race([
|
|
1281
|
+
(async () => {
|
|
1282
|
+
const { runtime: owner, release } = await acquire({
|
|
1283
|
+
invocationRoot,
|
|
1284
|
+
databasePath: agent.databasePath,
|
|
1285
|
+
});
|
|
1286
|
+
try {
|
|
1287
|
+
deadline.signal.throwIfAborted();
|
|
1288
|
+
let after;
|
|
1289
|
+
let stopped = false;
|
|
1290
|
+
do {
|
|
1291
|
+
const page = await owner.listSessions(after);
|
|
1292
|
+
deadline.signal.throwIfAborted();
|
|
1293
|
+
for (const row of page.sessions) {
|
|
1294
|
+
const session = owner.getSession(row.sessionId);
|
|
1295
|
+
const snapshot = await session.read({ limit: 1 });
|
|
1296
|
+
deadline.signal.throwIfAborted();
|
|
1297
|
+
if (snapshot.activity.queue.state !== "running" &&
|
|
1298
|
+
snapshot.pending.total === 0)
|
|
1299
|
+
continue;
|
|
1300
|
+
if (!snapshot.activity.cancellationRequested ||
|
|
1301
|
+
snapshot.pending.total > 0)
|
|
1302
|
+
await session.abort();
|
|
1303
|
+
// An accepted cancellation is not a completed stop. In particular,
|
|
1304
|
+
// first Esc may already have requested cancellation of this turn.
|
|
1305
|
+
// Keep the existing per-Agent deadline while awaiting durable idle.
|
|
1306
|
+
let settled = await session.read({ limit: 1 });
|
|
1307
|
+
while (settled.activity.queue.state === "running" ||
|
|
1308
|
+
settled.pending.total > 0) {
|
|
1309
|
+
await delay(20, undefined, { signal: deadline.signal });
|
|
1310
|
+
settled = await session.read({ limit: 1 });
|
|
1311
|
+
}
|
|
1312
|
+
deadline.signal.throwIfAborted();
|
|
1313
|
+
stopped = true;
|
|
1314
|
+
}
|
|
1315
|
+
after = page.next;
|
|
1316
|
+
} while (after !== undefined);
|
|
1317
|
+
return stopped;
|
|
1318
|
+
}
|
|
1319
|
+
finally {
|
|
1320
|
+
release();
|
|
1321
|
+
}
|
|
1322
|
+
})(),
|
|
1323
|
+
new Promise((_resolve, reject) => {
|
|
1324
|
+
timer = setTimeout(() => {
|
|
1325
|
+
const error = new Error("Agent did not answer within 5 seconds; stop is unconfirmed");
|
|
1326
|
+
deadline.abort(error);
|
|
1327
|
+
reject(error);
|
|
1328
|
+
}, 5_000);
|
|
1329
|
+
}),
|
|
1330
|
+
]);
|
|
1331
|
+
result.agents.push({
|
|
1332
|
+
agentId,
|
|
1333
|
+
name: agent.name,
|
|
1334
|
+
outcome: stopped ? "stopped" : "idle",
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
catch (cause) {
|
|
1338
|
+
result.agents.push({
|
|
1339
|
+
agentId,
|
|
1340
|
+
name: agent.name,
|
|
1341
|
+
outcome: "failed",
|
|
1342
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
finally {
|
|
1346
|
+
clearTimeout(timer);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return result;
|
|
1350
|
+
}
|
|
1351
|
+
return {
|
|
1352
|
+
refreshMesh: mesh.refresh,
|
|
1353
|
+
createSession,
|
|
1354
|
+
select,
|
|
1355
|
+
serve,
|
|
1356
|
+
effectiveModel,
|
|
1357
|
+
sandboxStatus,
|
|
1358
|
+
sweepIdle,
|
|
1359
|
+
archive,
|
|
1360
|
+
stopAll,
|
|
1361
|
+
restore,
|
|
1362
|
+
close,
|
|
1363
|
+
reload,
|
|
1364
|
+
unfinishedOwners: () => [
|
|
1365
|
+
...(reloading === undefined ? [] : ["configuration reload"]),
|
|
1366
|
+
...opening.values(),
|
|
1367
|
+
],
|
|
1368
|
+
};
|
|
371
1369
|
}
|