@fungi.computer/truffle 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +542 -2
- package/computerd-loopback.cjs +16 -0
- package/dist/auth-cli.d.ts +46 -0
- package/dist/auth-cli.js +373 -0
- package/dist/fungi-account-command.d.ts +18 -0
- package/dist/fungi-account-command.js +95 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- 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 +27487 -0
- package/dist/internal/sandbox-metafile.json +6726 -0
- package/dist/linear-auth.d.ts +4 -0
- package/dist/linear-auth.js +72 -0
- package/dist/local-archive-handoff.d.ts +12 -0
- package/dist/local-archive-handoff.js +21 -0
- package/dist/local-extensions.d.ts +143 -0
- package/dist/local-extensions.js +624 -0
- package/dist/local-image-fit.d.ts +30 -0
- package/dist/local-image-fit.js +105 -0
- package/dist/local-login.d.ts +55 -0
- package/dist/local-login.js +167 -0
- package/dist/local-models.d.ts +16 -0
- package/dist/local-models.js +82 -0
- package/dist/local-session-archives.d.ts +8 -0
- package/dist/local-session-archives.js +152 -0
- package/dist/local-skill-source.d.ts +21 -0
- package/dist/local-skill-source.js +433 -0
- package/dist/log-event.d.ts +9 -0
- package/dist/log-event.js +13 -0
- package/dist/private-bounded-response.d.ts +2 -0
- package/dist/private-bounded-response.js +41 -0
- package/dist/private-bun-shiitake-host.d.ts +11 -0
- package/dist/private-bun-shiitake-host.js +66 -0
- package/dist/private-file-credential-store.d.ts +58 -0
- package/dist/private-file-credential-store.js +331 -0
- package/dist/private-files-patch.d.ts +4 -0
- package/dist/private-files-patch.js +104 -0
- package/dist/private-fungi-account.d.ts +252 -0
- package/dist/private-fungi-account.js +1329 -0
- package/dist/private-fungi-auth-error.d.ts +5 -0
- package/dist/private-fungi-auth-error.js +7 -0
- package/dist/private-fungi-mcp-connection.d.ts +26 -0
- package/dist/private-fungi-mcp-connection.js +162 -0
- package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
- package/dist/private-fungi-mcp-endpoint.js +15 -0
- 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 +14 -0
- package/dist/private-fungi-mcp-selection.js +49 -0
- package/dist/private-fungi-model-api.d.ts +6 -0
- package/dist/private-fungi-model-api.js +125 -0
- package/dist/private-fungi-model-catalog.d.ts +16 -0
- package/dist/private-fungi-model-catalog.js +86 -0
- package/dist/private-fungi-model-provider.d.ts +6 -0
- package/dist/private-fungi-model-provider.js +51 -0
- package/dist/private-fungi-pending-logouts.d.ts +34 -0
- package/dist/private-fungi-pending-logouts.js +135 -0
- 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 +58 -0
- package/dist/private-invocation-computer.js +498 -0
- package/dist/private-invocation-files.d.ts +30 -0
- package/dist/private-invocation-files.js +520 -0
- package/dist/private-local-acp-stdio.d.ts +17 -0
- package/dist/private-local-acp-stdio.js +145 -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 +129 -0
- package/dist/private-local-auth-rpc.js +396 -0
- 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 +49 -0
- package/dist/private-local-computer-attach.js +230 -0
- package/dist/private-local-computer-backend.d.ts +282 -0
- package/dist/private-local-computer-backend.js +402 -0
- package/dist/private-local-computer-processes.d.ts +202 -0
- package/dist/private-local-computer-processes.js +613 -0
- 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-default-model.d.ts +11 -0
- package/dist/private-local-default-model.js +45 -0
- package/dist/private-local-delegation.d.ts +36 -0
- package/dist/private-local-delegation.js +133 -0
- package/dist/private-local-effective-model.d.ts +27 -0
- package/dist/private-local-effective-model.js +17 -0
- package/dist/private-local-exec-environment.d.ts +16 -0
- package/dist/private-local-exec-environment.js +11 -0
- package/dist/private-local-exec-launch.d.ts +41 -0
- package/dist/private-local-exec-launch.js +51 -0
- package/dist/private-local-exec-path.d.ts +6 -0
- package/dist/private-local-exec-path.js +40 -0
- package/dist/private-local-git-exclude.d.ts +2 -0
- package/dist/private-local-git-exclude.js +33 -0
- package/dist/private-local-http-dispatch.d.ts +5 -0
- package/dist/private-local-http-dispatch.js +14 -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 +13 -0
- package/dist/private-local-mail.js +77 -0
- 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 +192 -0
- package/dist/private-local-runtime-host.js +1177 -0
- 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 +170 -0
- package/dist/private-local-service-contract.js +1 -0
- package/dist/private-local-service-discovery.d.ts +217 -0
- package/dist/private-local-service-discovery.js +322 -0
- package/dist/private-local-service-ids.d.ts +3 -0
- package/dist/private-local-service-ids.js +8 -0
- 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 +22 -0
- package/dist/private-local-service-listener.js +407 -0
- 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 +135 -0
- package/dist/private-local-service-owner.js +1369 -0
- package/dist/private-local-service-process.d.ts +1 -0
- package/dist/private-local-service-process.js +8 -0
- 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 +46 -0
- package/dist/private-local-service.js +606 -0
- package/dist/private-local-session-discovery.d.ts +66 -0
- package/dist/private-local-session-discovery.js +278 -0
- package/dist/private-local-shiitake-host.d.ts +44 -0
- package/dist/private-local-shiitake-host.js +143 -0
- 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 +16 -0
- package/dist/private-local-worker-server.js +144 -0
- 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 +39 -0
- package/dist/private-sqlite-owner.js +169 -0
- package/dist/private-team-computers.d.ts +38 -0
- package/dist/private-team-computers.js +110 -0
- package/dist/private-workerd-sandbox.d.ts +3 -0
- package/dist/private-workerd-sandbox.js +347 -0
- 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 +24 -0
- package/dist/team-apps.js +128 -0
- package/dist/tracker-git-credential.d.ts +15 -0
- package/dist/tracker-git-credential.js +87 -0
- 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/build.json +4037 -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-apply-patch +0 -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 +161 -3
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { logEvent } from "./log-event.js";
|
|
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";
|
|
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";
|
|
11
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
12
|
+
import writeFileAtomic from "write-file-atomic";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import * as Schema from "effect/Schema";
|
|
15
|
+
import { selectionSchema } from "./private-local-service-discovery.js";
|
|
16
|
+
import { createShiitakeHttpRoutes } from "@fungi.computer/shiitake/http";
|
|
17
|
+
import { listPrivateLocalAgentStores, LocalStoreSchemaError, readPrivateLocalAgentProfile, readPrivateLocalSessionSummaries, isArchivedStore, unreadableStoreReason, } from "./private-local-session-discovery.js";
|
|
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";
|
|
25
|
+
export class LocalSessionPathConflict extends Error {
|
|
26
|
+
name = "LocalSessionPathConflict";
|
|
27
|
+
constructor() {
|
|
28
|
+
super("PATH differs for this live session; close its clients and restart the Local service before reconnecting from another toolchain");
|
|
29
|
+
}
|
|
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
|
+
}
|
|
44
|
+
// Reopen inputs only. Credentials remain with Local's account custodian.
|
|
45
|
+
const recoveryOwnerSchema = selectionSchema
|
|
46
|
+
.omit({ clientPath: true, sessionId: true, uiAttachmentLifetime: true })
|
|
47
|
+
.extend({ databasePath: z.string().min(1) });
|
|
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
|
+
}
|
|
59
|
+
/** One service owns each store, independently of how many clients select it. */
|
|
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 });
|
|
70
|
+
const recoveryPath = recoveryDirectory === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: resolve(recoveryDirectory, "owners.json");
|
|
73
|
+
const recovery = new Map();
|
|
74
|
+
let loadingRecovery;
|
|
75
|
+
let saving = Promise.resolve();
|
|
76
|
+
const emptySessions = new Map();
|
|
77
|
+
const owners = new Map();
|
|
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();
|
|
240
|
+
const pendingOpenings = new Set();
|
|
241
|
+
let stopping = false;
|
|
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();
|
|
262
|
+
function saveRecovery() {
|
|
263
|
+
if (recoveryPath === undefined)
|
|
264
|
+
return Promise.resolve();
|
|
265
|
+
const path = recoveryPath;
|
|
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
|
+
});
|
|
278
|
+
// A recovery hint write must not hide a live owner or poison later saves.
|
|
279
|
+
saving = next.catch((cause) => {
|
|
280
|
+
logEvent("service-owner.save-recovery", cause);
|
|
281
|
+
});
|
|
282
|
+
return saving;
|
|
283
|
+
}
|
|
284
|
+
function trackOpening(pending, invocationRoot, key) {
|
|
285
|
+
const opened = pending;
|
|
286
|
+
// Runtime ownership survives a failed recovery-reference write. Shutdown
|
|
287
|
+
// must still drain it before the service can release its owner lock.
|
|
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) => {
|
|
293
|
+
opening.delete(opened);
|
|
294
|
+
logEvent("service-owner.open-runtime", cause);
|
|
295
|
+
});
|
|
296
|
+
pending = pending.then(async (owner) => {
|
|
297
|
+
roots.set(owner.runtime.databasePath, invocationRoot);
|
|
298
|
+
if (recoveryPath !== undefined) {
|
|
299
|
+
const reference = {
|
|
300
|
+
invocationRoot,
|
|
301
|
+
databasePath: owner.runtime.databasePath,
|
|
302
|
+
};
|
|
303
|
+
if (owner.fungiMcp !== undefined)
|
|
304
|
+
reference.fungiMcp = { endpoint: owner.fungiMcp.endpoint.href };
|
|
305
|
+
recovery.set(owner.runtime.databasePath, reference);
|
|
306
|
+
await saveRecovery();
|
|
307
|
+
}
|
|
308
|
+
return owner;
|
|
309
|
+
});
|
|
310
|
+
owners.set(key, pending);
|
|
311
|
+
pendingOpenings.add(pending);
|
|
312
|
+
void pending.then((owner) => {
|
|
313
|
+
owners.set(`store:${owner.runtime.databasePath}`, pending);
|
|
314
|
+
const projectKey = `project:${invocationRoot}`;
|
|
315
|
+
if (!owners.has(projectKey))
|
|
316
|
+
owners.set(projectKey, pending);
|
|
317
|
+
pendingOpenings.delete(pending);
|
|
318
|
+
}, () => {
|
|
319
|
+
if (owners.get(key) === pending)
|
|
320
|
+
owners.delete(key);
|
|
321
|
+
pendingOpenings.delete(pending);
|
|
322
|
+
});
|
|
323
|
+
return pending;
|
|
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
|
+
}
|
|
368
|
+
function ensureRunning() {
|
|
369
|
+
if (stopping)
|
|
370
|
+
throw new Error("Local service is stopping");
|
|
371
|
+
}
|
|
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) {
|
|
589
|
+
return {
|
|
590
|
+
agentId: owner.agentId,
|
|
591
|
+
databasePath: owner.databasePath,
|
|
592
|
+
invocationRoot,
|
|
593
|
+
sessionId,
|
|
594
|
+
session: gatedSession(owner, sessionId),
|
|
595
|
+
/** End this selection's use; the mounted route reopens the Agent later. */
|
|
596
|
+
release,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function createSession(creation) {
|
|
600
|
+
return select(creation, creation.requestId);
|
|
601
|
+
}
|
|
602
|
+
function belongsToProject(invocationRoot, databasePath) {
|
|
603
|
+
const within = relative(invocationRoot, databasePath);
|
|
604
|
+
return !isAbsolute(within) && within !== ".." && !within.startsWith("../");
|
|
605
|
+
}
|
|
606
|
+
async function selectedDatabasePath(invocationRoot, requested) {
|
|
607
|
+
const databasePath = requested === undefined
|
|
608
|
+
? undefined
|
|
609
|
+
: await realpath(resolve(invocationRoot, requested));
|
|
610
|
+
if (databasePath !== undefined)
|
|
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;
|
|
617
|
+
const stores = await listPrivateLocalAgentStores(invocationRoot);
|
|
618
|
+
ensureRunning();
|
|
619
|
+
if (stores.length > 1)
|
|
620
|
+
throw new LocalAgentSelectionAmbiguous(stores.map((store) => basename(dirname(store))));
|
|
621
|
+
return stores[0];
|
|
622
|
+
}
|
|
623
|
+
async function ownerFor(selection) {
|
|
624
|
+
while (pendingReload() !== undefined)
|
|
625
|
+
await pendingReload();
|
|
626
|
+
ensureRunning();
|
|
627
|
+
const invocationRoot = await realpath(selection.invocationRoot);
|
|
628
|
+
const databasePath = await selectedDatabasePath(invocationRoot, selection.databasePath);
|
|
629
|
+
ensureRunning();
|
|
630
|
+
const fungiMcp = selection.fungiMcp === undefined
|
|
631
|
+
? undefined
|
|
632
|
+
: readFungiMcpSelection(selection.fungiMcp, {
|
|
633
|
+
credentialPath: configRoot.credentialPath,
|
|
634
|
+
});
|
|
635
|
+
ensureRunning();
|
|
636
|
+
// A new project selection shares its in-flight owner; explicit resume keys the store.
|
|
637
|
+
const key = databasePath === undefined
|
|
638
|
+
? `project:${invocationRoot}`
|
|
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);
|
|
646
|
+
let pending = owners.get(key);
|
|
647
|
+
if (pending === undefined && pendingOpenings.size > 0) {
|
|
648
|
+
// A fresh project does not reveal its canonical store path until opening.
|
|
649
|
+
// Let current openings install both project and store aliases, then recheck.
|
|
650
|
+
await Promise.allSettled(pendingOpenings);
|
|
651
|
+
while (pendingReload() !== undefined)
|
|
652
|
+
await pendingReload();
|
|
653
|
+
ensureRunning();
|
|
654
|
+
pending = owners.get(key);
|
|
655
|
+
}
|
|
656
|
+
const openingHere = pending === undefined;
|
|
657
|
+
if (pending === undefined) {
|
|
658
|
+
const projectControl = databasePath === undefined ||
|
|
659
|
+
belongsToProject(invocationRoot, databasePath);
|
|
660
|
+
let options = {
|
|
661
|
+
configRoot,
|
|
662
|
+
invocationRoot,
|
|
663
|
+
databasePath,
|
|
664
|
+
// The host must persist/admit this BEFORE Shiitake can recover work.
|
|
665
|
+
requireSandbox: selection.requireSandbox,
|
|
666
|
+
agentControl: projectControl ? directory(invocationRoot) : undefined,
|
|
667
|
+
mail: projectControl ? mailRouting(invocationRoot) : undefined,
|
|
668
|
+
meshAdmission,
|
|
669
|
+
};
|
|
670
|
+
if (fungiMcp !== undefined)
|
|
671
|
+
options = { ...options, fungiMcp };
|
|
672
|
+
const captured = configuration;
|
|
673
|
+
if (captured !== undefined)
|
|
674
|
+
options = {
|
|
675
|
+
...options,
|
|
676
|
+
configuration: (store) => loadConfiguration(captured, invocationRoot, store),
|
|
677
|
+
};
|
|
678
|
+
pending = trackOpening(openRuntime(options).then((runtime) => ({ runtime, fungiMcp })), invocationRoot, key);
|
|
679
|
+
}
|
|
680
|
+
const owner = await pending;
|
|
681
|
+
ensureRunning();
|
|
682
|
+
if (configuration === undefined &&
|
|
683
|
+
fungiMcp !== undefined &&
|
|
684
|
+
!sameFungiMcpConnection(owner.fungiMcp, fungiMcp))
|
|
685
|
+
throw new FungiMcpSelectionConflict();
|
|
686
|
+
if (!openingHere && selection.requireSandbox !== undefined)
|
|
687
|
+
owner.runtime.setRequireSandbox(selection.requireSandbox);
|
|
688
|
+
return owner.runtime;
|
|
689
|
+
}
|
|
690
|
+
async function select(selection, createRequestId) {
|
|
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) {
|
|
701
|
+
let sessionId = selection.sessionId;
|
|
702
|
+
if (createRequestId !== undefined) {
|
|
703
|
+
sessionId = await owner.createSession(createRequestId);
|
|
704
|
+
ensureRunning();
|
|
705
|
+
}
|
|
706
|
+
else if (sessionId === undefined) {
|
|
707
|
+
const summaries = await readPrivateLocalSessionSummaries(owner.databasePath);
|
|
708
|
+
sessionId = summaries[0]?.sessionId;
|
|
709
|
+
if (sessionId === undefined) {
|
|
710
|
+
let empty = emptySessions.get(owner.databasePath);
|
|
711
|
+
if (empty === undefined) {
|
|
712
|
+
empty = owner.createSession(createPrivateLocalSessionRequestId());
|
|
713
|
+
emptySessions.set(owner.databasePath, empty);
|
|
714
|
+
}
|
|
715
|
+
sessionId = await empty;
|
|
716
|
+
}
|
|
717
|
+
ensureRunning();
|
|
718
|
+
}
|
|
719
|
+
if (selection.clientPath !== undefined &&
|
|
720
|
+
!owner.bindSessionPath(sessionId, selection.clientPath))
|
|
721
|
+
throw new LocalSessionPathConflict();
|
|
722
|
+
return sessionId;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Project mail routing for every Agent this service owns in one project.
|
|
726
|
+
* Delivery opens an idle addressed Agent, so its admitted mail wakes it; the
|
|
727
|
+
* recipient's own ingress decides what it accepts.
|
|
728
|
+
*/
|
|
729
|
+
function mailRouting(invocationRoot) {
|
|
730
|
+
const control = directory(invocationRoot);
|
|
731
|
+
return {
|
|
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
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function directory(invocationRoot) {
|
|
776
|
+
async function targets(signal, visibility = "active") {
|
|
777
|
+
ensureRunning();
|
|
778
|
+
signal.throwIfAborted();
|
|
779
|
+
const paths = await listPrivateLocalAgentStores(invocationRoot, visibility);
|
|
780
|
+
signal.throwIfAborted();
|
|
781
|
+
const result = new Map();
|
|
782
|
+
for (const databasePath of paths) {
|
|
783
|
+
// Discovery can follow symlinks for human resume; model control cannot
|
|
784
|
+
// cross the canonical project boundary through one of those links.
|
|
785
|
+
if (!belongsToProject(invocationRoot, databasePath))
|
|
786
|
+
continue;
|
|
787
|
+
const agentId = basename(dirname(databasePath));
|
|
788
|
+
if (result.has(agentId))
|
|
789
|
+
throw new Error("Local Agent identity is ambiguous");
|
|
790
|
+
result.set(agentId, databasePath);
|
|
791
|
+
}
|
|
792
|
+
return result;
|
|
793
|
+
}
|
|
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);
|
|
852
|
+
if (databasePath === undefined)
|
|
853
|
+
throw new Error("Agent is outside this Local project");
|
|
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
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
owner: admittedOwner,
|
|
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) => {
|
|
930
|
+
ensureRunning();
|
|
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
|
+
}
|
|
948
|
+
const key = `create:${JSON.stringify([invocationRoot, requestId])}`;
|
|
949
|
+
const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId);
|
|
950
|
+
signal.throwIfAborted();
|
|
951
|
+
ensureRunning();
|
|
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();
|
|
963
|
+
let pending = owners.get(key);
|
|
964
|
+
if (pending === undefined) {
|
|
965
|
+
pending = owners.get(`store:${databasePath}`);
|
|
966
|
+
if (pending === undefined) {
|
|
967
|
+
let options = {
|
|
968
|
+
configRoot,
|
|
969
|
+
invocationRoot,
|
|
970
|
+
createRequestId: requestId,
|
|
971
|
+
name,
|
|
972
|
+
createdBy,
|
|
973
|
+
requireSandbox: sandbox.requireSandbox,
|
|
974
|
+
folder: sandbox.folder,
|
|
975
|
+
agentControl: directory(invocationRoot),
|
|
976
|
+
mail: mailRouting(invocationRoot),
|
|
977
|
+
};
|
|
978
|
+
if (model !== undefined)
|
|
979
|
+
options = {
|
|
980
|
+
...options,
|
|
981
|
+
providerId: model.providerId,
|
|
982
|
+
modelId: model.modelId,
|
|
983
|
+
reasoning: model.reasoning,
|
|
984
|
+
};
|
|
985
|
+
const captured = configuration;
|
|
986
|
+
if (captured !== undefined)
|
|
987
|
+
options = {
|
|
988
|
+
...options,
|
|
989
|
+
configuration: (store) => loadConfiguration(captured, invocationRoot, store),
|
|
990
|
+
};
|
|
991
|
+
pending = trackOpening(openRuntime(options).then((runtime) => ({
|
|
992
|
+
runtime,
|
|
993
|
+
fungiMcp: undefined,
|
|
994
|
+
})), invocationRoot, key);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const owner = await pending;
|
|
998
|
+
signal.throwIfAborted();
|
|
999
|
+
ensureRunning();
|
|
1000
|
+
if (owner.runtime.databasePath !== databasePath)
|
|
1001
|
+
throw new Error("Local Agent create identity does not match its store");
|
|
1002
|
+
owners.set(key, pending);
|
|
1003
|
+
const release = hold(databasePath);
|
|
1004
|
+
try {
|
|
1005
|
+
await owner.runtime.assertModelReady("main", signal);
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
release();
|
|
1009
|
+
}
|
|
1010
|
+
const agentId = createStore.agentId;
|
|
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 });
|
|
1018
|
+
},
|
|
1019
|
+
setModel: async ({ agentId, model }, signal) => {
|
|
1020
|
+
const { owner, release } = await admittedOwner(agentId, signal);
|
|
1021
|
+
try {
|
|
1022
|
+
await owner.setModel(model, signal);
|
|
1023
|
+
}
|
|
1024
|
+
finally {
|
|
1025
|
+
release();
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
checkModel: async ({ agentId, sessionId }, signal) => {
|
|
1029
|
+
const { owner, release } = await admittedOwner(agentId, signal);
|
|
1030
|
+
try {
|
|
1031
|
+
await owner.assertModelReady(sessionId, signal);
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
release();
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
open: async (agentId, signal) => {
|
|
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
|
+
};
|
|
1045
|
+
return {
|
|
1046
|
+
agent: {
|
|
1047
|
+
// Work starts only through an admission that finds the Agent in use.
|
|
1048
|
+
session: (sessionId) => gatedSession(owner, sessionId),
|
|
1049
|
+
listSessions: owner.listSessions,
|
|
1050
|
+
createSession: (requestId) => admitted(owner, () => owner.createSession(requestId)),
|
|
1051
|
+
deleteSession: owner.deleteSession,
|
|
1052
|
+
extensions: owner.extensions,
|
|
1053
|
+
close: release,
|
|
1054
|
+
},
|
|
1055
|
+
release,
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function close() {
|
|
1061
|
+
stopping = true;
|
|
1062
|
+
clearInterval(sweepTimer);
|
|
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]);
|
|
1074
|
+
await Promise.allSettled(pendingOpenings);
|
|
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
|
+
})));
|
|
1085
|
+
const failures = [];
|
|
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);
|
|
1103
|
+
}
|
|
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 }));
|
|
1124
|
+
owners.clear();
|
|
1125
|
+
opening.clear();
|
|
1126
|
+
pendingOpenings.clear();
|
|
1127
|
+
if (failures.length > 0)
|
|
1128
|
+
throw new AggregateError(failures, "Local service owners failed to close");
|
|
1129
|
+
})();
|
|
1130
|
+
return closing;
|
|
1131
|
+
}
|
|
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() {
|
|
1147
|
+
if (recoveryPath === undefined)
|
|
1148
|
+
return { kind: "loaded", references: [] };
|
|
1149
|
+
let content;
|
|
1150
|
+
try {
|
|
1151
|
+
content = await readRecoveryManifest(recoveryPath);
|
|
1152
|
+
}
|
|
1153
|
+
catch (cause) {
|
|
1154
|
+
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
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);
|
|
1159
|
+
}
|
|
1160
|
+
const decoded = decodeRecoveryFile(content);
|
|
1161
|
+
if (decoded._tag === "Failure") {
|
|
1162
|
+
return disableRecovery("decode", decoded.failure);
|
|
1163
|
+
}
|
|
1164
|
+
const saved = [];
|
|
1165
|
+
for (const entry of decoded.success) {
|
|
1166
|
+
const selection = recoveryOwnerSchema.safeParse(entry);
|
|
1167
|
+
if (!selection.success) {
|
|
1168
|
+
logEvent("service-owner.decode-recovery-entry", selection.error);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
saved.push(selection.data);
|
|
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);
|
|
1175
|
+
}
|
|
1176
|
+
return { kind: "loaded", references: saved };
|
|
1177
|
+
}
|
|
1178
|
+
async function restoreSaved() {
|
|
1179
|
+
const loaded = await loadRecovery();
|
|
1180
|
+
if (loaded.kind === "disabled")
|
|
1181
|
+
return;
|
|
1182
|
+
// Preload valid references before opening, then isolate and prune each
|
|
1183
|
+
// failed owner so an unavailable project cannot prevent service startup.
|
|
1184
|
+
for (const selection of loaded.references) {
|
|
1185
|
+
if (stopping)
|
|
1186
|
+
break;
|
|
1187
|
+
try {
|
|
1188
|
+
await ownerFor(selection);
|
|
1189
|
+
}
|
|
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;
|
|
1195
|
+
logEvent("service-owner.restore-entry", cause);
|
|
1196
|
+
// A good owner may already have replaced another stale reference to
|
|
1197
|
+
// this store. Prune only the failing reference, never that live owner.
|
|
1198
|
+
if (recovery.get(selection.databasePath) === selection)
|
|
1199
|
+
recovery.delete(selection.databasePath);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
await saveRecovery();
|
|
1203
|
+
}
|
|
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
|
+
};
|
|
1369
|
+
}
|