@xenosystem/agent-interface-runtime 0.1.12
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 +67 -0
- package/dist/cloudOptions.d.ts +28 -0
- package/dist/cloudOptions.d.ts.map +1 -0
- package/dist/cloudOptions.js +37 -0
- package/dist/cloudOptions.js.map +1 -0
- package/dist/composeHost.d.ts +171 -0
- package/dist/composeHost.d.ts.map +1 -0
- package/dist/composeHost.js +877 -0
- package/dist/composeHost.js.map +1 -0
- package/dist/detachedHostEntry.d.ts +51 -0
- package/dist/detachedHostEntry.d.ts.map +1 -0
- package/dist/detachedHostEntry.js +185 -0
- package/dist/detachedHostEntry.js.map +1 -0
- package/dist/hostLanePolicy.d.ts +122 -0
- package/dist/hostLanePolicy.d.ts.map +1 -0
- package/dist/hostLanePolicy.js +86 -0
- package/dist/hostLanePolicy.js.map +1 -0
- package/dist/hostServices.d.ts +56 -0
- package/dist/hostServices.d.ts.map +1 -0
- package/dist/hostServices.js +74 -0
- package/dist/hostServices.js.map +1 -0
- package/dist/hostSupervisorAdapter.d.ts +31 -0
- package/dist/hostSupervisorAdapter.d.ts.map +1 -0
- package/dist/hostSupervisorAdapter.js +59 -0
- package/dist/hostSupervisorAdapter.js.map +1 -0
- package/dist/node.d.ts +7 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +7 -0
- package/dist/node.js.map +1 -0
- package/dist/observationConfig.d.ts +45 -0
- package/dist/observationConfig.d.ts.map +1 -0
- package/dist/observationConfig.js +78 -0
- package/dist/observationConfig.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agent host composition โ ONE of them, used by every product surface.
|
|
3
|
+
*
|
|
4
|
+
* ADE ยง9 build-order step 3 needs the host to be able to run in a process with
|
|
5
|
+
* no windows. The obvious way to get there is to write a second, smaller
|
|
6
|
+
* composition for the detached entry. That is a fork, and this repo's own
|
|
7
|
+
* invariant (`AGENTS.md`: no surface-specific fork) says why it is the wrong
|
|
8
|
+
* shape: two compositions drift, and the one that drifts is always the one
|
|
9
|
+
* fewer people run.
|
|
10
|
+
*
|
|
11
|
+
* So there is one builder, and the Electron-only capabilities are INJECTED. A
|
|
12
|
+
* surface that has windows passes them; a headless process passes none.
|
|
13
|
+
*
|
|
14
|
+
* ## ๐ด Absent capabilities are declared, never silently dropped
|
|
15
|
+
*
|
|
16
|
+
* โ ๏ธ CORRECTED 2026-08-18. This said the SDK-native lane cannot run without
|
|
17
|
+
* `createSdkRuntimeWindow`, "its tools take a live BrowserWindow". Measured, that
|
|
18
|
+
* is false: the loop takes an `SdkAgentSurface` (send + isDestroyed) and loads in
|
|
19
|
+
* plain Node, and the surface tools act through `SdkAgentSurfaceRuntimeAdapter`,
|
|
20
|
+
* whose unregistered default refuses honestly. A window is now one way to run the
|
|
21
|
+
* lane; the headless runner is the other, and it differs only in where events go
|
|
22
|
+
* and who can answer a prompt.
|
|
23
|
+
*
|
|
24
|
+
* The principle the paragraph was written for is untouched, and still applies to
|
|
25
|
+
* every capability that IS genuinely absent here: the tempting response is to
|
|
26
|
+
* leave the provider out of the catalog entirely.
|
|
27
|
+
*
|
|
28
|
+
* That is wrong, and the reason generalises: **a provider that vanishes looks
|
|
29
|
+
* like a configuration error.** An operator sees a shorter list and goes looking
|
|
30
|
+
* for what they broke. A provider that is PRESENT and reports
|
|
31
|
+
* `runnable: false` with a reason has told them the truth โ this build cannot
|
|
32
|
+
* run that lane, and here is why. The descriptor already carries
|
|
33
|
+
* `safeUnavailableReason` for exactly this, so honesty costs a string.
|
|
34
|
+
*
|
|
35
|
+
* ## ยง9 step 3 โ ADOPTED
|
|
36
|
+
*
|
|
37
|
+
* Both entries run this. `apps/desktop/src/main/index.ts` calls it with a full
|
|
38
|
+
* `surface`; `detachedHostEntry.ts` calls it with `{}`. The fork it existed to
|
|
39
|
+
* remove is gone, and with it the failure mode that made the case: for weeks
|
|
40
|
+
* each composition was missing services the other had, in both directions โ
|
|
41
|
+
* the four ยง5 services reached the detached host and never the app, while
|
|
42
|
+
* tier-1 observation and the ยง5.7 landing queue reached the app and never the
|
|
43
|
+
* detached host. Neither gap is visible from inside one composition, and a test
|
|
44
|
+
* that mounts its own fixture cannot see them at all.
|
|
45
|
+
*
|
|
46
|
+
* ๐ด `createCoordinator()` is DEFERRED, and that is the one thing not to
|
|
47
|
+
* "simplify". Composing is safe in any process; the coordinator opens the
|
|
48
|
+
* durable SQLite state, and two of those open at once is the split-brain the
|
|
49
|
+
* host lease exists to prevent. So the composition is eager โ a surface still
|
|
50
|
+
* needs its shell service and registry while another process owns the host โ
|
|
51
|
+
* and only the coordinator waits for the election.
|
|
52
|
+
*/
|
|
53
|
+
import { XENO_AGENT_HOST_MIN_PROTOCOL_VERSION, XENO_AGENT_HOST_PROTOCOL_VERSION, } from '@xenosystem/agent-interface-contract';
|
|
54
|
+
import { createHash } from 'node:crypto';
|
|
55
|
+
import { join } from 'node:path';
|
|
56
|
+
import { AcpAgentRegistryService, AcpNativeProviderLauncherResolver, AcpAgentTurnExecutionAdapter, AcpInteractivePermissionBridge, AgentHostCoordinator, DirectoryBaselineScanner, DurableAcpPermissionPolicyStore, DurableInterruptedTurnStore, DurablePendingInjectionStore, EngineeringWorkbenchService, LandingService, MountContentReader, ProviderLaneService, RunExecutionEnvironment, RunObservationService, RunIsolationService, RuntimeEventStore, RuntimeEventStoreRepositoryAdapter, SdkNativeAgentTurnExecutionAdapter, SubagentScheduler, setSubagentScheduler, createHostTurnSubagentExecutionAdapter, getSubagentRunService, WorkspaceHostService, WorkspaceShellService, XenoCloudAgentTurnExecutionAdapter, XenoUseRunObserver, createAcpApprovalPortAdapter, createAcpAgentRegistryPort, createAcpNativeTuiLane, createAcpRuntimeProviderDescriptors, createAgentStateRepository, createFixtureAcpAgentConfig, createSdkNativeRuntimeProviderDescriptor, createXenoCloudRuntimeProviderDescriptor, resolveLocalAgentHostPaths, toXenoCloudAgentModelDescriptors, } from '@xenosystem/agent-interface-host/node';
|
|
57
|
+
import { createXenoCloudNodeAgentsClient, discoverXenoCloudNodeRuntime, readXenoCloudCredentials, } from '@xenosystem/agents-client/node';
|
|
58
|
+
/**
|
|
59
|
+
* ๐ด `@xenosystem/agent-interface-electron-host` is imported
|
|
60
|
+
* DYNAMICALLY, everywhere, and only when a surface supplies a window.
|
|
61
|
+
*
|
|
62
|
+
* Its `sdkAgent` module does `import { app, dialog, ipcMain } from 'electron'`
|
|
63
|
+
* at module scope. A static import here puts `electron` in the shared main
|
|
64
|
+
* chunk, so the detached host entry โ a plain Node process โ dies on
|
|
65
|
+
* `SyntaxError: Named export 'app' not found` before it runs a line.
|
|
66
|
+
*
|
|
67
|
+
* โ ๏ธ Spawning it as `electron --run-as-node` does NOT rescue this: under
|
|
68
|
+
* `ELECTRON_RUN_AS_NODE` the `electron` module resolves to the executable's
|
|
69
|
+
* PATH STRING, not the API, so the named import fails there too.
|
|
70
|
+
*
|
|
71
|
+
* The headless composition test did not catch it, because vite resolves
|
|
72
|
+
* `electron` in-process. Only spawning the BUILT entry did.
|
|
73
|
+
*/
|
|
74
|
+
import { createSharedHostServices } from './hostServices.js';
|
|
75
|
+
import { declaredUnavailableLanes, deliverElicitationThrough, sdkUnavailableReasonFor } from './hostLanePolicy.js';
|
|
76
|
+
import { resolveObservationConfig } from './observationConfig.js';
|
|
77
|
+
import { standaloneCloudOptions } from './cloudOptions.js';
|
|
78
|
+
export async function composeAgentHost(options) {
|
|
79
|
+
const env = options.env ?? process.env;
|
|
80
|
+
const fixtureEnabled = env.XENO_AGENT_ENABLE_FIXTURE === '1';
|
|
81
|
+
const paths = resolveLocalAgentHostPaths({
|
|
82
|
+
homeDirectory: options.homeDirectory,
|
|
83
|
+
...(env.XENO_AGENT_HOST_ROOT ? { hostRootDirectory: env.XENO_AGENT_HOST_ROOT } : {}),
|
|
84
|
+
...(env.XENO_AGENT_ACP_REGISTRY_ROOT
|
|
85
|
+
? { acpRegistryRootDirectory: env.XENO_AGENT_ACP_REGISTRY_ROOT }
|
|
86
|
+
: {}),
|
|
87
|
+
});
|
|
88
|
+
const hostRoot = paths.hostRootDirectory;
|
|
89
|
+
const registry = new AcpAgentRegistryService({ rootDir: paths.acpRegistryRootDirectory });
|
|
90
|
+
if (fixtureEnabled) {
|
|
91
|
+
const fixtureMode = env.XENO_AGENT_FIXTURE_MODE || 'normal';
|
|
92
|
+
const fixturePath = env.XENO_AGENT_FIXTURE_PATH
|
|
93
|
+
|| join(options.fixtureBaseDirectory, '..', '..', 'tests', 'fixtures', 'acp', 'fixture-acp-agent.mjs');
|
|
94
|
+
const fixture = createFixtureAcpAgentConfig(registry.rootDir, fixtureMode, 'fixture-acp');
|
|
95
|
+
const saved = registry.saveAgent({
|
|
96
|
+
...fixture,
|
|
97
|
+
args: [fixturePath, `--mode=${fixtureMode}`],
|
|
98
|
+
env: { XENO_ACP_FIXTURE_MODE: fixtureMode, ELECTRON_RUN_AS_NODE: '1' },
|
|
99
|
+
});
|
|
100
|
+
if (!saved.success)
|
|
101
|
+
throw new Error(saved.error || 'Could not register the standalone ACP fixture.');
|
|
102
|
+
}
|
|
103
|
+
const runtimeStore = new RuntimeEventStore(paths.runtimeEventsDirectory);
|
|
104
|
+
const cloudOptions = standaloneCloudOptions({ homeDirectory: options.homeDirectory, hostRoot, env });
|
|
105
|
+
const cloudClient = createXenoCloudNodeAgentsClient(cloudOptions);
|
|
106
|
+
const cloudRunClient = {
|
|
107
|
+
createRun: (input, runOptions) => cloudClient.createRun(input, runOptions),
|
|
108
|
+
attach: (runId, runOptions) => cloudClient.attach(runId, {
|
|
109
|
+
transport: 'sse',
|
|
110
|
+
reconnect: true,
|
|
111
|
+
...(runOptions?.signal ? { signal: runOptions.signal } : {}),
|
|
112
|
+
}),
|
|
113
|
+
getRun: (runId, runOptions) => cloudClient.getRun(runId, runOptions),
|
|
114
|
+
stopRun: (runId, runOptions) => cloudClient.stopRun(runId, runOptions),
|
|
115
|
+
};
|
|
116
|
+
const acpPermissionPolicyStore = new DurableAcpPermissionPolicyStore({ rootDirectory: hostRoot });
|
|
117
|
+
const interruptedTurnStore = new DurableInterruptedTurnStore({ rootDirectory: hostRoot });
|
|
118
|
+
/**
|
|
119
|
+
* Pinned nodes waiting for a next turn (XENO SPAWN ยง2.2, ยง3).
|
|
120
|
+
*
|
|
121
|
+
* Durable here for the same reason the two stores above it are: this host has a real root, and
|
|
122
|
+
* ยง3 puts a `deferred` row on an append-only record the moment a node is pinned. Held in memory,
|
|
123
|
+
* a restart left that row promising a delivery nothing could still make.
|
|
124
|
+
*/
|
|
125
|
+
const pendingInjectionStore = new DurablePendingInjectionStore({ rootDirectory: hostRoot });
|
|
126
|
+
const acpPermissionBridge = new AcpInteractivePermissionBridge({
|
|
127
|
+
messenger: {
|
|
128
|
+
send: (channel, payload) => {
|
|
129
|
+
const send = options.surface.sendElicitation;
|
|
130
|
+
// Throwing is the CORRECT direction here: the bridge treats an
|
|
131
|
+
// unreachable surface as a refusal, and a permission prompt nobody can
|
|
132
|
+
// answer must not resolve as an approval.
|
|
133
|
+
if (!send)
|
|
134
|
+
throw new Error('This Agent host has no surface able to show a permission prompt.');
|
|
135
|
+
send(channel, payload);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const permissionPolicyService = {
|
|
140
|
+
listRules: () => ({
|
|
141
|
+
success: true,
|
|
142
|
+
rules: acpPermissionPolicyStore.list().map((rule) => ({
|
|
143
|
+
id: rule.id,
|
|
144
|
+
decision: rule.decision,
|
|
145
|
+
...(rule.kind ? { toolKind: rule.kind } : {}),
|
|
146
|
+
...(rule.pathPrefix ? { pathPrefix: rule.pathPrefix } : {}),
|
|
147
|
+
...(rule.createdAt === undefined ? {} : { createdAt: rule.createdAt }),
|
|
148
|
+
})),
|
|
149
|
+
}),
|
|
150
|
+
revokeRule: (input) => ({
|
|
151
|
+
success: true,
|
|
152
|
+
revoked: acpPermissionPolicyStore.remove(input.ruleId),
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
// Loaded only when the SDK lane can actually run. A headless composition has
|
|
156
|
+
// no window, cannot run that lane at all (it is declared unrunnable below),
|
|
157
|
+
// and so has no reason to pay for โ or be broken by โ this module.
|
|
158
|
+
const sdkInteractionService = options.surface.createSdkRuntimeWindow
|
|
159
|
+
? (await import('@xenosystem/agent-interface-electron-host'))
|
|
160
|
+
.createSdkNativeInteractionService()
|
|
161
|
+
: undefined;
|
|
162
|
+
const interactionService = {
|
|
163
|
+
answerPermission: (input) => {
|
|
164
|
+
const routed = acpPermissionBridge.answerPermission({
|
|
165
|
+
permissionId: input.permissionId,
|
|
166
|
+
granted: input.granted,
|
|
167
|
+
...(input.selectedOptionId ? { selectedOptionId: input.selectedOptionId } : {}),
|
|
168
|
+
});
|
|
169
|
+
if (routed.handled)
|
|
170
|
+
return { success: true };
|
|
171
|
+
// ๐ด Not silently "handled". With no SDK lane there is nobody to route
|
|
172
|
+
// this to, and reporting success for an answer nothing received would
|
|
173
|
+
// leave the asker waiting forever on a prompt already marked resolved.
|
|
174
|
+
if (!sdkInteractionService) {
|
|
175
|
+
return { success: false, error: 'This host has no SDK-native lane to answer.' };
|
|
176
|
+
}
|
|
177
|
+
return sdkInteractionService.answerPermission(input);
|
|
178
|
+
},
|
|
179
|
+
answerAskUser: sdkInteractionService
|
|
180
|
+
? sdkInteractionService.answerAskUser
|
|
181
|
+
: () => ({ success: false, error: 'This host has no SDK-native lane to answer.' }),
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The four ยง5 services, from the ONE builder both compositions call.
|
|
185
|
+
*
|
|
186
|
+
* ๐ด They used to be constructed here and NOWHERE else, which meant they
|
|
187
|
+
* reached this composition โ the detached host โ and not the app the user
|
|
188
|
+
* launches. Closing that by pasting the constructions into `index.ts` would
|
|
189
|
+
* have left two spellings of the same decisions in the two files least likely
|
|
190
|
+
* to be read together, so the decisions moved to `hostServices.ts` instead.
|
|
191
|
+
*/
|
|
192
|
+
const { knowledgeService, intakeService, sessionService, coordinationService, coordinationPort, } = createSharedHostServices({
|
|
193
|
+
resolveIntakeRoots: () => workspaceAuthority.grantedDirectories(),
|
|
194
|
+
});
|
|
195
|
+
const engineeringControlService = new EngineeringWorkbenchService({ runtimeEventStore: runtimeStore });
|
|
196
|
+
/**
|
|
197
|
+
* Tier-1 run observation (ADE ยง2.9).
|
|
198
|
+
*
|
|
199
|
+
* ๐ด Built HERE, in the shared composition, and that is a fix rather than a
|
|
200
|
+
* move. It used to live only in `index.ts`, so the DETACHED host โ the whole
|
|
201
|
+
* point of ยง9 step 3 โ ran with no observation and no landing gate at all:
|
|
202
|
+
* the exact mirror of the four ยง5 services that used to reach only the
|
|
203
|
+
* detached host and never the app. A capability that exists in one
|
|
204
|
+
* composition and not the other is the fork this module exists to remove.
|
|
205
|
+
*
|
|
206
|
+
* ๐ **Out of process, HTTP only** (locked decision 17). `xeno-use` is
|
|
207
|
+
* AGPL-3.0 and this app is proprietary, so nothing here imports it, links its
|
|
208
|
+
* N-API binding, or takes it as a dependency. The observer speaks its
|
|
209
|
+
* `/v1/use/*` surface and nothing else.
|
|
210
|
+
*
|
|
211
|
+
* ๐ด **Unconfigured means NO observation, not an empty one.** With no
|
|
212
|
+
* endpoint the service is not constructed and no binder is passed, so the host
|
|
213
|
+
* reports review unavailable and `mayLand` refuses for want of a diff โ all
|
|
214
|
+
* of which is true and correct. An empty service would instead report every
|
|
215
|
+
* run as a clean diff of changes nobody watched.
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* Records where EVERY run executes โ with or without observation configured.
|
|
219
|
+
*
|
|
220
|
+
* ๐ด Deliberately outside the `if (observationConfig.enabled)` block below.
|
|
221
|
+
* Isolation and observation are different questions, and a run that nothing
|
|
222
|
+
* watches is still a run whose containment the user is entitled to know. Bound
|
|
223
|
+
* here with no sandbox, so it resolves to the local executor and reports
|
|
224
|
+
* `none` โ a fact, not an absence. Binding only observed runs would leave
|
|
225
|
+
* every other run reporting `unknown`, which reads as "cannot account for it"
|
|
226
|
+
* when the truth is plainly "nothing confines it".
|
|
227
|
+
*/
|
|
228
|
+
const bindRunExecution = (context) => {
|
|
229
|
+
// ๐ด CONFINEMENT REQUIRES A DIRECTORY TO MOUNT. A run with none has nothing
|
|
230
|
+
// to bind a sandbox around, so it is bound unconfined and reports `none` โ
|
|
231
|
+
// truthfully. Substituting a directory would mount the wrong tree.
|
|
232
|
+
if (!resolveRunSandbox || !context.primaryDirectory) {
|
|
233
|
+
runExecutionEnvironment.bind({
|
|
234
|
+
runId: context.runId,
|
|
235
|
+
conversationId: context.conversationId,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
runExecutionEnvironment.bind({
|
|
240
|
+
runId: context.runId,
|
|
241
|
+
conversationId: context.conversationId,
|
|
242
|
+
pendingSandbox: resolveRunSandbox(context.runId, context.primaryDirectory),
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
const knownRunIds = new Set();
|
|
246
|
+
let runObservationService;
|
|
247
|
+
let runObserver;
|
|
248
|
+
let observationBegin;
|
|
249
|
+
/**
|
|
250
|
+
* Spawns (or joins) the run's sandbox and describes it for execution.
|
|
251
|
+
*
|
|
252
|
+
* Defined only when a `xeno-use` endpoint is configured โ so with no sandbox
|
|
253
|
+
* substrate installed, `bindRunExecution` binds every run unconfined and the
|
|
254
|
+
* app reports `none`, which is the truth and is what ยง5.3 asks it to say.
|
|
255
|
+
*/
|
|
256
|
+
let resolveRunSandbox;
|
|
257
|
+
const observationConfig = resolveObservationConfig(env);
|
|
258
|
+
if (observationConfig.reason) {
|
|
259
|
+
// Said once, at startup. Half-configured otherwise fails silently, one 401
|
|
260
|
+
// per run, with nothing pointing at configuration as the cause.
|
|
261
|
+
console.warn(`[xeno-agent-host] run observation is off: ${observationConfig.reason}`);
|
|
262
|
+
}
|
|
263
|
+
if (observationConfig.enabled && observationConfig.baseUrl && observationConfig.token) {
|
|
264
|
+
runObservationService = new RunObservationService();
|
|
265
|
+
const observer = new XenoUseRunObserver({
|
|
266
|
+
baseUrl: observationConfig.baseUrl,
|
|
267
|
+
token: observationConfig.token,
|
|
268
|
+
...(observationConfig.image ? { image: observationConfig.image } : {}),
|
|
269
|
+
service: runObservationService,
|
|
270
|
+
baselineScanner: new DirectoryBaselineScanner(),
|
|
271
|
+
// Hunk content is read from the HOST side of the mount โ a bind mount is
|
|
272
|
+
// the host directory, so this needs no sandbox call and no dependency.
|
|
273
|
+
createContentReader: ({ hostDirectory, containerDirectory }) => new MountContentReader({ mounts: [{ source: hostDirectory, target: containerDirectory }] }),
|
|
274
|
+
onProblem: ({ runId, reason }) => console.warn(`[xeno-agent-host] observation problem${runId ? ` for ${runId}` : ''}: ${reason}`),
|
|
275
|
+
});
|
|
276
|
+
runObserver = observer;
|
|
277
|
+
observationBegin = ({ runId, primaryDirectory }) => {
|
|
278
|
+
// ๐ด Recorded BEFORE the early return below.
|
|
279
|
+
//
|
|
280
|
+
// A run with no directory is never observed, and that is exactly the run
|
|
281
|
+
// the landing gate must not silently ignore. Recording it here is what
|
|
282
|
+
// lets `landingOverlaps` report the combination as incomplete instead of
|
|
283
|
+
// clearing a merge over work nobody watched.
|
|
284
|
+
knownRunIds.add(runId);
|
|
285
|
+
// No directory means nothing to watch. Substituting one would mount the
|
|
286
|
+
// wrong tree into a sandbox and report another project's files as this
|
|
287
|
+
// run's work.
|
|
288
|
+
if (!primaryDirectory)
|
|
289
|
+
return;
|
|
290
|
+
// Not awaited: attaching spawns a container and the turn is the user's
|
|
291
|
+
// actual work. `start` resolves false rather than throwing, and an
|
|
292
|
+
// unobserved run is reported honestly everywhere downstream.
|
|
293
|
+
void observer.start(runId, { hostDirectory: primaryDirectory });
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Routes EXECUTION into the sandbox this run is already observed in.
|
|
297
|
+
*
|
|
298
|
+
* ๐ด One container, both roles โ which is ADE ยง2.9's architecture ("every
|
|
299
|
+
* run is sandboxed, so the sandbox is an observation plane"), not a
|
|
300
|
+
* shortcut. `isolationIsNotObservation.test.ts` forbids deriving
|
|
301
|
+
* `sandboxed` from an observation binding *while execution still goes to
|
|
302
|
+
* the host*; its own stated exit is "turn execution actually being routed
|
|
303
|
+
* into one", which is this. The binding was never sufficient on its own โ
|
|
304
|
+
* it is necessary, and now it is accompanied.
|
|
305
|
+
*
|
|
306
|
+
* The spawn is handed over as a PENDING sandbox rather than awaited here.
|
|
307
|
+
* Awaiting would stall every turn behind a container start; not awaiting at
|
|
308
|
+
* all would let the first command run on the host and still report
|
|
309
|
+
* `sandboxed`. Passing the promise makes the EXECUTOR wait instead, so no
|
|
310
|
+
* command reaches the host and no turn is blocked for a run that never
|
|
311
|
+
* executes anything.
|
|
312
|
+
*/
|
|
313
|
+
resolveRunSandbox = async (runId, primaryDirectory) => {
|
|
314
|
+
const started = await observer.start(runId, { hostDirectory: primaryDirectory });
|
|
315
|
+
if (!started)
|
|
316
|
+
return undefined;
|
|
317
|
+
const descriptor = observer.sandboxFor(runId);
|
|
318
|
+
if (!descriptor)
|
|
319
|
+
return undefined;
|
|
320
|
+
return {
|
|
321
|
+
sandboxId: descriptor.sandboxId,
|
|
322
|
+
mount: {
|
|
323
|
+
hostDirectory: descriptor.hostDirectory,
|
|
324
|
+
containerDirectory: descriptor.containerDirectory,
|
|
325
|
+
},
|
|
326
|
+
baseUrl: observationConfig.baseUrl,
|
|
327
|
+
token: observationConfig.token,
|
|
328
|
+
mechanism: 'container (xeno-use)',
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The binder the host always gets.
|
|
334
|
+
*
|
|
335
|
+
* ๐ด Unconditional, because isolation must be recorded for every run whether
|
|
336
|
+
* or not observation is configured โ and the two used to share one optional
|
|
337
|
+
* hook, so an unobserved run was also an unaccounted-for run. The observation
|
|
338
|
+
* half stays conditional inside it: with no endpoint `runObservationService`
|
|
339
|
+
* is still undefined, so review remains unavailable and `mayLand` still
|
|
340
|
+
* refuses for want of a diff. Nothing about that changed.
|
|
341
|
+
*/
|
|
342
|
+
const runObservationBinder = {
|
|
343
|
+
begin: (context) => {
|
|
344
|
+
bindRunExecution({
|
|
345
|
+
runId: context.runId,
|
|
346
|
+
conversationId: context.conversationId,
|
|
347
|
+
...(context.primaryDirectory === undefined
|
|
348
|
+
? {}
|
|
349
|
+
: { primaryDirectory: context.primaryDirectory }),
|
|
350
|
+
});
|
|
351
|
+
observationBegin?.(context);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* ยง5.7's landing queue โ the serialised, gated path to trunk.
|
|
356
|
+
*
|
|
357
|
+
* ๐ด Its overlap picture is read from the OBSERVATION service above. With
|
|
358
|
+
* observation unconfigured there are no observed diffs, so `landingOverlaps()`
|
|
359
|
+
* reports nothing and the gate refuses every landing as `unverified` โ
|
|
360
|
+
* correct, and not a degradation to route around: conflict-aware landing
|
|
361
|
+
* without observed change sets would be a verdict about a world nobody
|
|
362
|
+
* looked at.
|
|
363
|
+
*
|
|
364
|
+
* The merge itself is a plain `git merge --no-ff` of the run's branch.
|
|
365
|
+
* `--no-ff` on purpose: a landing is a decision, and a decision should be
|
|
366
|
+
* visible in the history as one commit that can be reverted, rather than
|
|
367
|
+
* dissolved into trunk by a fast-forward.
|
|
368
|
+
*/
|
|
369
|
+
engineeringControlService.configureLandingService(new LandingService({
|
|
370
|
+
context: {
|
|
371
|
+
contextFor: () => ({
|
|
372
|
+
overlaps: runObservationService
|
|
373
|
+
// ๐ด The known set is passed, not just the observed one. Without it
|
|
374
|
+
// the gate compares only what it happened to watch and calls the
|
|
375
|
+
// answer complete โ which is how a merge lands on top of an
|
|
376
|
+
// unobserved run's work.
|
|
377
|
+
? runObservationService.landingOverlaps({}, [...knownRunIds])
|
|
378
|
+
// An empty report, which yields `unverified` and refuses. Deliberately
|
|
379
|
+
// NOT a fabricated all-clear: the gate must never clear a landing on
|
|
380
|
+
// facts nobody collected.
|
|
381
|
+
: { runs: [], overlaps: [], complete: false, incompleteRuns: [] },
|
|
382
|
+
verifications: [],
|
|
383
|
+
}),
|
|
384
|
+
},
|
|
385
|
+
checkRunner: {
|
|
386
|
+
run: async ({ rootDirectory, checkId }) => {
|
|
387
|
+
const discovered = engineeringControlService.discoverChecks({ rootDirectory });
|
|
388
|
+
const check = checkId
|
|
389
|
+
? discovered.checks?.find((candidate) => candidate.id === checkId)
|
|
390
|
+
: discovered.checks?.[0];
|
|
391
|
+
if (!check) {
|
|
392
|
+
return { passed: false, checks: [], problem: 'No check could be discovered for this workspace.' };
|
|
393
|
+
}
|
|
394
|
+
const result = await engineeringControlService.runCheck({ rootDirectory, checkId: check.id });
|
|
395
|
+
// ๐ด `exitCode === 0`, not `result.success`. `success` means the check
|
|
396
|
+
// RAN; a suite that ran perfectly and reported three failures is a
|
|
397
|
+
// successful run of a red check, and treating that as green is the
|
|
398
|
+
// whole failure this gate exists to prevent.
|
|
399
|
+
const passed = result.run?.exitCode === 0;
|
|
400
|
+
return {
|
|
401
|
+
passed,
|
|
402
|
+
checks: [check.id],
|
|
403
|
+
...(passed
|
|
404
|
+
? {}
|
|
405
|
+
: {
|
|
406
|
+
problem: result.error
|
|
407
|
+
?? result.run?.error
|
|
408
|
+
?? `${check.id} exited ${result.run?.exitCode ?? 'without a status'}`,
|
|
409
|
+
}),
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
executor: {
|
|
414
|
+
merge: async ({ rootDirectory, runId, base }) => {
|
|
415
|
+
const branch = await engineeringControlService.gitBranch({
|
|
416
|
+
rootDirectory,
|
|
417
|
+
name: base || 'main',
|
|
418
|
+
create: false,
|
|
419
|
+
});
|
|
420
|
+
if (!branch.success) {
|
|
421
|
+
return { merged: false, error: branch.error || `Could not switch to ${base || 'main'} to land.` };
|
|
422
|
+
}
|
|
423
|
+
return await engineeringControlService.gitMergeForLanding({ rootDirectory, branch: runId });
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
}));
|
|
427
|
+
/**
|
|
428
|
+
* Where each run's commands execute, and what containment to report โ ADE ยง5.3.
|
|
429
|
+
*
|
|
430
|
+
* ๐ด One object answers both, so the app cannot confine a run while telling
|
|
431
|
+
* the user it does not, or claim containment it does not have. It is
|
|
432
|
+
* constructed unconditionally: with no sandbox bound it hands out the local
|
|
433
|
+
* executor and reports `none`, which is exactly today's truth and is what
|
|
434
|
+
* ยง5.3 asks the product to say out loud.
|
|
435
|
+
*
|
|
436
|
+
* Binding a run to an execution sandbox happens in `bindRunExecution` when
|
|
437
|
+
* a `xeno-use` endpoint is configured. Workspace commands, the agent's
|
|
438
|
+
* Bash tool, and the workspace PTY all resolve through this object. A
|
|
439
|
+
* confined PTY is `/bin/sh -i` with `tty: true` on `CommandStreamPort`.
|
|
440
|
+
*/
|
|
441
|
+
const runExecutionEnvironment = new RunExecutionEnvironment();
|
|
442
|
+
let providerLaneServiceForPty;
|
|
443
|
+
const workspaceAuthority = new WorkspaceHostService({
|
|
444
|
+
rootDirectory: paths.workspaceAuthorityRoot,
|
|
445
|
+
managedConversationRoot: paths.managedConversationRoot,
|
|
446
|
+
executionEnvironment: runExecutionEnvironment,
|
|
447
|
+
providerSessionPtyResolver: (profile, cwd, platform) => (providerLaneServiceForPty?.resolveProviderSessionPty(profile, cwd, platform)
|
|
448
|
+
?? { code: 'NATIVE_TUI_UNSUPPORTED', error: 'Provider lane authority is not ready.' }),
|
|
449
|
+
});
|
|
450
|
+
const workspaceShellService = options.surface.shell
|
|
451
|
+
? new WorkspaceShellService({ workspaceAuthority, shell: options.surface.shell })
|
|
452
|
+
// Absent rather than a stub. A shell service that accepted "reveal in
|
|
453
|
+
// folder" and did nothing would report success it does not have.
|
|
454
|
+
: undefined;
|
|
455
|
+
/**
|
|
456
|
+
* ๐ด Two ways to run the SDK-native lane now, and a window is only one of them.
|
|
457
|
+
*
|
|
458
|
+
* A composition with a renderer runs it through `createSdkNativeElectronRunner`, exactly as
|
|
459
|
+
* before. A composition without one runs the SAME loop through the headless runner, which
|
|
460
|
+
* routes the two blocking channels to attached clients and REFUSES them when nobody is there.
|
|
461
|
+
* That refusal is what makes the headless lane runnable rather than a trap: an elicitation that
|
|
462
|
+
* is neither answered nor refused wedges the turn forever, because `requestSurfacePermission`
|
|
463
|
+
* has no timeout.
|
|
464
|
+
*
|
|
465
|
+
* So the lane is runnable in both, and `canResolveElicitations` is true in both โ for different
|
|
466
|
+
* reasons, which is why the parameter asks about resolving rather than about a window.
|
|
467
|
+
*/
|
|
468
|
+
const hasWindow = Boolean(options.surface.createSdkRuntimeWindow);
|
|
469
|
+
const sdkRunnable = true;
|
|
470
|
+
const declaredUnavailable = declaredUnavailableLanes({
|
|
471
|
+
hasWindow,
|
|
472
|
+
canResolveElicitations: true,
|
|
473
|
+
});
|
|
474
|
+
const loadProviders = () => loadAgentHostProviders(registry, cloudOptions, {
|
|
475
|
+
sdkRunnable,
|
|
476
|
+
// The store decides, not a literal โ one object, so the declaration cannot drift from it.
|
|
477
|
+
durablePendingInjections: pendingInjectionStore.durable,
|
|
478
|
+
});
|
|
479
|
+
const providers = await loadProviders();
|
|
480
|
+
// Typed as the shared interface, not inferred: inference from the initial two
|
|
481
|
+
// entries produces a union that the SDK adapter is not a member of, and the
|
|
482
|
+
// fix for that must not be a cast โ the whole point is that every lane
|
|
483
|
+
// satisfies one contract.
|
|
484
|
+
const turnExecutionAdapters = [
|
|
485
|
+
new AcpAgentTurnExecutionAdapter({
|
|
486
|
+
registryService: registry,
|
|
487
|
+
runtimeStore,
|
|
488
|
+
permissionDelegate: acpPermissionBridge,
|
|
489
|
+
permissionPolicyStore: acpPermissionPolicyStore,
|
|
490
|
+
/**
|
|
491
|
+
* \u{1F534} A PORT, not the service. What an ACP agent may do is exactly these
|
|
492
|
+
* four calls; passing the service itself would put `endRun` and
|
|
493
|
+
* `setInboundPolicy` within reach of the lane, and those are the host's.
|
|
494
|
+
*/
|
|
495
|
+
coordinationPort,
|
|
496
|
+
/**
|
|
497
|
+
* \u{1F534} Late-bound on purpose. The adapter is built BEFORE the coordinator
|
|
498
|
+
* that owns the roster โ the same ordering `setEventSink` already solves
|
|
499
|
+
* โ so this closes over the binding rather than the value. Before the
|
|
500
|
+
* coordinator exists there is no run to report on, so the no-op is
|
|
501
|
+
* correct rather than a swallowed signal.
|
|
502
|
+
*/
|
|
503
|
+
onRunActivity: (signal) => { coordinator?.reportRunActivity(signal); },
|
|
504
|
+
...(fixtureEnabled ? { requestTimeoutMs: 5_000 } : {}),
|
|
505
|
+
}),
|
|
506
|
+
new XenoCloudAgentTurnExecutionAdapter({ client: cloudRunClient, runtimeStore }),
|
|
507
|
+
];
|
|
508
|
+
if (!options.surface.createSdkRuntimeWindow) {
|
|
509
|
+
// The headless lane. Same loop, same coordination port, same run-activity reporter โ the
|
|
510
|
+
// only difference is where its events go and who can answer its prompts.
|
|
511
|
+
const { createSdkNativeHeadlessRunner } = await import('@xenosystem/agent-interface-electron-host/headless');
|
|
512
|
+
turnExecutionAdapters.splice(1, 0, new SdkNativeAgentTurnExecutionAdapter(createSdkNativeHeadlessRunner({
|
|
513
|
+
getAuthToken: () => readXenoCloudCredentials(cloudOptions).apiKey || null,
|
|
514
|
+
getPlatformAuthToken: () => readXenoCloudCredentials(cloudOptions).webToken || null,
|
|
515
|
+
runtimeEventStore: runtimeStore,
|
|
516
|
+
executionEnvironment: runExecutionEnvironment,
|
|
517
|
+
/**
|
|
518
|
+
* ๐ด Delivery is answered from HANDSHAKES, never from subscribers. The RPC server
|
|
519
|
+
* subscribes exactly one listener and fans out to however many sockets it holds, so a
|
|
520
|
+
* listener count is equally consistent with ten attached clients and none. Only a client
|
|
521
|
+
* that completed a handshake AND declared `canPresentElicitations` can actually answer.
|
|
522
|
+
*
|
|
523
|
+
* Late-bound through `coordinator` for the same reason `onRunActivity` is: the adapters
|
|
524
|
+
* are built before the coordinator that owns the client registry exists.
|
|
525
|
+
*/
|
|
526
|
+
deliverElicitation: (channel, payload) => deliverElicitationThrough(coordinator, channel, payload),
|
|
527
|
+
}), coordinationPort, (signal) => { coordinator?.reportRunActivity(signal); },
|
|
528
|
+
// The SAME store the windowed lane gets: a pin is made against a CONVERSATION, and which
|
|
529
|
+
// lane happens to execute its next turn is not something an operator chose.
|
|
530
|
+
pendingInjectionStore));
|
|
531
|
+
}
|
|
532
|
+
if (options.surface.createSdkRuntimeWindow) {
|
|
533
|
+
// Registered only where it can actually run. The adapter registry answers
|
|
534
|
+
// `adapter_unavailable` BY NAME for an unregistered lane, so an attempt to
|
|
535
|
+
// use it here fails with the reason rather than with a crash inside a tool
|
|
536
|
+
// reaching for a window that does not exist.
|
|
537
|
+
const { createSdkNativeElectronRunner } = await import('@xenosystem/agent-interface-electron-host');
|
|
538
|
+
const sdkRunner = createSdkNativeElectronRunner({
|
|
539
|
+
getAuthToken: () => readXenoCloudCredentials(cloudOptions).apiKey || null,
|
|
540
|
+
getPlatformAuthToken: () => readXenoCloudCredentials(cloudOptions).webToken || null,
|
|
541
|
+
createRuntimeWindow: options.surface.createSdkRuntimeWindow,
|
|
542
|
+
runtimeEventStore: runtimeStore,
|
|
543
|
+
executionEnvironment: runExecutionEnvironment,
|
|
544
|
+
});
|
|
545
|
+
// The SAME port the ACP lane gets. Two vocabularies, one implementation โ a
|
|
546
|
+
// primitive that behaved differently by lane would leave an agent with two
|
|
547
|
+
// answers and no way to tell which to believe.
|
|
548
|
+
turnExecutionAdapters.splice(1, 0, new SdkNativeAgentTurnExecutionAdapter(sdkRunner, coordinationPort,
|
|
549
|
+
// The SAME reporter the ACP lane gets. A run blocked on a human must look
|
|
550
|
+
// the same to a peer whichever runtime is executing it.
|
|
551
|
+
(signal) => { coordinator?.reportRunActivity(signal); }, pendingInjectionStore));
|
|
552
|
+
}
|
|
553
|
+
// Read by `onRunActivity` above, which is created first; see the note there.
|
|
554
|
+
let coordinator;
|
|
555
|
+
/**
|
|
556
|
+
* Subagent execution โ ADE ยง5.2, step 6.
|
|
557
|
+
*
|
|
558
|
+
* ๐ด The adapter runs each subagent as a TURN on this host, so a fan-out inherits the same
|
|
559
|
+
* admission budget, deadlines and abandonment as every other surface's work rather than getting
|
|
560
|
+
* a second execution path that would drift from all three.
|
|
561
|
+
*
|
|
562
|
+
* โ ๏ธ Provider selection goes through `fleet.plan`, not a local capability check. Duplicating the
|
|
563
|
+
* matching would give the fleet two answers to "who can run this", and the one nobody is looking
|
|
564
|
+
* at would be the one that drifts.
|
|
565
|
+
*/
|
|
566
|
+
// ๐ด Anchored to THIS host's home. The default is the real user's `~/.xeno`, which an
|
|
567
|
+
// isolated host must never write to โ and until this line existed, every one did.
|
|
568
|
+
const subagentRunService = getSubagentRunService(join(options.homeDirectory, '.xeno', 'subagent-runs.json'));
|
|
569
|
+
const subagentScheduler = new SubagentScheduler(subagentRunService, runtimeStore);
|
|
570
|
+
// Diagnostics read the REGISTERED scheduler. Without this line they build their own, report its
|
|
571
|
+
// empty status as the system's, and an operator reads "scheduler not running" during an incident
|
|
572
|
+
// where it is running fine.
|
|
573
|
+
setSubagentScheduler(subagentScheduler);
|
|
574
|
+
/**
|
|
575
|
+
* The scheduler is a SURFACE, so it must complete the host handshake before its first turn.
|
|
576
|
+
*
|
|
577
|
+
* ๐ด It never did, and the consequence was invisible: every `turn.start` was refused with
|
|
578
|
+
* "The requesting surface must complete the host handshake before starting a turn.", the adapter
|
|
579
|
+
* read a refusal as an acceptance, and the run sat in `running` with zero runtime events until its
|
|
580
|
+
* lease expired. Measured with scripts/delegation-probe.mjs.
|
|
581
|
+
*
|
|
582
|
+
* โ ๏ธ Cached as a PROMISE so concurrent launches share one handshake instead of racing to
|
|
583
|
+
* register the same surface โ and NOT cached on failure, because a handshake that failed while the
|
|
584
|
+
* coordinator was still starting must stay retryable rather than poisoning every later run.
|
|
585
|
+
*
|
|
586
|
+
* โ ๏ธ It lives here rather than in the adapter because the adapter's port is deliberately
|
|
587
|
+
* `turn.start | turn.cancel` only. The composition owns the coordinator and the identity below;
|
|
588
|
+
* the adapter should not need a handshake-capable host to be testable.
|
|
589
|
+
*/
|
|
590
|
+
const subagentIdentity = {
|
|
591
|
+
clientId: 'xeno-agent-subagent-scheduler',
|
|
592
|
+
clientVersion: options.hostVersion,
|
|
593
|
+
instanceId: `${options.hostInstanceId}:subagents`,
|
|
594
|
+
surface: 'headless-client',
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Host-event subscribers registered BEFORE the coordinator exists.
|
|
598
|
+
*
|
|
599
|
+
* ๐ด `subscribe: (listener) => coordinator?.subscribe(...) ?? (() => {})` ran at adapter
|
|
600
|
+
* CONSTRUCTION, when `coordinator` is still undefined โ so it returned the no-op and the subagent
|
|
601
|
+
* adapter never received one host event. It therefore never saw `turn.execution.completed` and
|
|
602
|
+
* never settled a run: a child that finished successfully left its run at `running` until the
|
|
603
|
+
* lease expired, at which point recovery RE-RAN work that had already succeeded.
|
|
604
|
+
*
|
|
605
|
+
* โ ๏ธ The `request` property beside it is late-bound correctly, because it re-reads
|
|
606
|
+
* `coordinator` inside an async function on every call. The same comment covered both and only
|
|
607
|
+
* one of them actually deferred.
|
|
608
|
+
*/
|
|
609
|
+
const pendingHostSubscribers = new Map();
|
|
610
|
+
const subscribeToHost = (listener) => {
|
|
611
|
+
if (coordinator)
|
|
612
|
+
return coordinator.subscribe(listener);
|
|
613
|
+
// โ ๏ธ The slot is captured by the returned unsubscribe, so it keeps working after the flush
|
|
614
|
+
// clears the map โ the caller cannot tell whether it subscribed before or after the
|
|
615
|
+
// coordinator existed, which is what makes this correct rather than merely working today.
|
|
616
|
+
const slot = {};
|
|
617
|
+
pendingHostSubscribers.set(listener, slot);
|
|
618
|
+
return () => {
|
|
619
|
+
pendingHostSubscribers.delete(listener);
|
|
620
|
+
slot.dispose?.();
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
const attachPendingHostSubscribers = () => {
|
|
624
|
+
if (!coordinator)
|
|
625
|
+
return;
|
|
626
|
+
for (const [listener, slot] of pendingHostSubscribers) {
|
|
627
|
+
slot.dispose = coordinator.subscribe(listener);
|
|
628
|
+
}
|
|
629
|
+
pendingHostSubscribers.clear();
|
|
630
|
+
};
|
|
631
|
+
let subagentHandshake;
|
|
632
|
+
const ensureSubagentHandshake = async () => {
|
|
633
|
+
if (subagentHandshake)
|
|
634
|
+
return subagentHandshake;
|
|
635
|
+
subagentHandshake = (async () => {
|
|
636
|
+
try {
|
|
637
|
+
await coordinator?.request('host.handshake', {
|
|
638
|
+
identity: subagentIdentity,
|
|
639
|
+
supportedProtocol: {
|
|
640
|
+
min: XENO_AGENT_HOST_MIN_PROTOCOL_VERSION,
|
|
641
|
+
max: XENO_AGENT_HOST_PROTOCOL_VERSION,
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
subagentHandshake = undefined;
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
})();
|
|
650
|
+
return subagentHandshake;
|
|
651
|
+
};
|
|
652
|
+
const subagentAdapter = createHostTurnSubagentExecutionAdapter({
|
|
653
|
+
// Late-bound for the same reason as `onRunActivity`: adapters are built before the
|
|
654
|
+
// coordinator that executes their turns exists.
|
|
655
|
+
host: {
|
|
656
|
+
request: (async (method, request) => {
|
|
657
|
+
if (!coordinator)
|
|
658
|
+
throw new Error('The Agent host is not ready to execute subagent runs.');
|
|
659
|
+
// Handshake first, every time, idempotently โ see ensureSubagentHandshake above.
|
|
660
|
+
if (method !== 'host.handshake')
|
|
661
|
+
await ensureSubagentHandshake();
|
|
662
|
+
return coordinator.request(method, request);
|
|
663
|
+
}),
|
|
664
|
+
subscribe: (listener) => subscribeToHost(listener),
|
|
665
|
+
},
|
|
666
|
+
identity: subagentIdentity,
|
|
667
|
+
selectProvider: async (run) => {
|
|
668
|
+
/**
|
|
669
|
+
* โ ๏ธ No `requiredCapabilities` is passed, and that is deliberate rather than a TODO.
|
|
670
|
+
* `SubagentCapabilityMatch` carries score/reasons/missingCapabilities โ it is the RESULT of
|
|
671
|
+
* matching, not the requirement โ so there is nothing on a run to forward. Inventing a
|
|
672
|
+
* requirement here would make `fleet.plan` refuse runs for a constraint no one stated.
|
|
673
|
+
* Carrying the requirement onto the run is a contract change, not a line of glue.
|
|
674
|
+
*/
|
|
675
|
+
// ๐ด AWAITED. `request` is async, and the previous version cast the Promise to a plain
|
|
676
|
+
// object with `as unknown as` โ so `assignments` was always undefined, selection always
|
|
677
|
+
// failed, and every subagent run terminated with "No runtime could be selected for this
|
|
678
|
+
// subagent run." The cast is what made it typecheck.
|
|
679
|
+
const plan = await coordinator?.request('fleet.plan', {
|
|
680
|
+
tasks: [{ taskId: run.id }],
|
|
681
|
+
});
|
|
682
|
+
const providerId = plan?.assignments?.[0]?.providerId;
|
|
683
|
+
if (!providerId)
|
|
684
|
+
return undefined;
|
|
685
|
+
const provider = providers.find((entry) => entry.id === providerId);
|
|
686
|
+
const modelId = provider?.models.find((model) => model.isDefault)?.id ?? provider?.models[0]?.id;
|
|
687
|
+
if (!provider || !modelId)
|
|
688
|
+
return undefined;
|
|
689
|
+
return { providerId, catalogRevision: provider.catalogRevision, modelId };
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
subagentScheduler.registerAdapter(subagentAdapter);
|
|
693
|
+
/**
|
|
694
|
+
* The port the host gets: create and list from the run service, cancel from the SCHEDULER โ
|
|
695
|
+
* because cancelling a launched run has to reach the adapter that launched it.
|
|
696
|
+
*/
|
|
697
|
+
const subagentRuns = {
|
|
698
|
+
createRun: (request) => {
|
|
699
|
+
const result = subagentRunService.createRun(request);
|
|
700
|
+
// ๐ด Drain immediately, or a created run waits for the next sweep โ and before this line
|
|
701
|
+
// existed, forever: nothing claimed a queued run at all, so `subagent.run.create` persisted a
|
|
702
|
+
// run that never executed. Proven with scripts/delegation-probe.mjs, which saw `queued` sixty
|
|
703
|
+
// seconds later with zero runtime events.
|
|
704
|
+
//
|
|
705
|
+
// โ ๏ธ Safe to call on every create because the ceiling lives in `claimNextRun`, which counts
|
|
706
|
+
// active runs per slot and refuses beyond `maxConcurrentRuns`. This adds no policy; it only
|
|
707
|
+
// stops the queue from standing still.
|
|
708
|
+
if (result.success)
|
|
709
|
+
subagentScheduler.drain();
|
|
710
|
+
return result;
|
|
711
|
+
},
|
|
712
|
+
listRuns: (request) => subagentRunService.listRuns(request),
|
|
713
|
+
cancelRun: (request) => subagentScheduler.cancelRun(request),
|
|
714
|
+
};
|
|
715
|
+
// The periodic sweep recovers runs whose worker died AND launches anything queued while every
|
|
716
|
+
// slot was full. It was never started, so neither happened.
|
|
717
|
+
subagentScheduler.start();
|
|
718
|
+
let stateRepository;
|
|
719
|
+
const createCoordinator = async () => {
|
|
720
|
+
// Idempotent: the lease is won once, but shutdown and retry paths can race,
|
|
721
|
+
// and opening the durable state twice from one process is no better than
|
|
722
|
+
// opening it from two.
|
|
723
|
+
if (coordinator)
|
|
724
|
+
return coordinator;
|
|
725
|
+
stateRepository = await createAgentStateRepository(paths);
|
|
726
|
+
const runtimeEventRepository = new RuntimeEventStoreRepositoryAdapter(runtimeStore);
|
|
727
|
+
const nativeLauncherResolver = new AcpNativeProviderLauncherResolver(registry);
|
|
728
|
+
const providerLaneService = new ProviderLaneService({
|
|
729
|
+
repository: stateRepository,
|
|
730
|
+
workspaceService: workspaceAuthority,
|
|
731
|
+
hostInstanceId: options.hostInstanceId,
|
|
732
|
+
hostStorageIdentity: createHash('sha256')
|
|
733
|
+
.update(`xeno-agent-state-v2\0${paths.stateDatabaseFile.toLowerCase()}`, 'utf8')
|
|
734
|
+
.digest('hex'),
|
|
735
|
+
getProviders: () => loadProviders(),
|
|
736
|
+
resolveNativeLauncher: (input) => nativeLauncherResolver.resolve(input),
|
|
737
|
+
runtimeEventRepository,
|
|
738
|
+
});
|
|
739
|
+
providerLaneServiceForPty = providerLaneService;
|
|
740
|
+
coordinator = new AgentHostCoordinator({
|
|
741
|
+
hostVersion: options.hostVersion,
|
|
742
|
+
hostInstanceId: options.hostInstanceId,
|
|
743
|
+
processBoundary: 'per-user-service',
|
|
744
|
+
storageSchemaVersion: 2,
|
|
745
|
+
eventSchemaVersion: 1,
|
|
746
|
+
providers,
|
|
747
|
+
providerCatalogLoader: () => loadProviders(),
|
|
748
|
+
runtimeEventRepository,
|
|
749
|
+
/**
|
|
750
|
+
* ADE ยง2.8's `native-tui` lane, finally reachable.
|
|
751
|
+
*
|
|
752
|
+
* Resolved from the ACP registry: a provider offers its own interface iff
|
|
753
|
+
* its agent config carries a `nativeTui` command, which is written only
|
|
754
|
+
* when the vendor's binary was actually FOUND on PATH. So a machine
|
|
755
|
+
* without Claude CLI installed reports `nativeTui: false` for
|
|
756
|
+
* `claude-local` and the mode is refused by name โ the honest answer,
|
|
757
|
+
* not a broken session.
|
|
758
|
+
*
|
|
759
|
+
* ๐ด Passing the resolver rather than a snapshot: the registry changes
|
|
760
|
+
* when a user adds an agent, and a value captured here would answer for
|
|
761
|
+
* the providers that existed at boot.
|
|
762
|
+
*/
|
|
763
|
+
nativeTuiLane: createAcpNativeTuiLane(registry),
|
|
764
|
+
acpAgentRegistry: createAcpAgentRegistryPort(registry),
|
|
765
|
+
stateRepository,
|
|
766
|
+
workspaceService: workspaceAuthority,
|
|
767
|
+
interactionService,
|
|
768
|
+
permissionPolicyService,
|
|
769
|
+
interruptedTurnStore,
|
|
770
|
+
// Same rule as the interaction service: absent without a window, never a
|
|
771
|
+
// stub that accepts control commands for a lane that cannot run.
|
|
772
|
+
...(options.surface.createSdkRuntimeWindow
|
|
773
|
+
? {
|
|
774
|
+
sdkControlService: (await import('@xenosystem/agent-interface-electron-host'))
|
|
775
|
+
.createSdkNativeControlService(),
|
|
776
|
+
}
|
|
777
|
+
: {}),
|
|
778
|
+
engineeringControlService,
|
|
779
|
+
providerLaneService,
|
|
780
|
+
...(runObservationService ? { runObservationService } : {}),
|
|
781
|
+
runObservationBinder,
|
|
782
|
+
knowledgeService,
|
|
783
|
+
intakeService,
|
|
784
|
+
sessionService,
|
|
785
|
+
coordinationService,
|
|
786
|
+
// ๐ด Supplied with NO source, and that is the point rather than a gap.
|
|
787
|
+
// ยง5.3 requires the user to always know whether a run is contained, so
|
|
788
|
+
// the shipped app must ANSWER โ and with no substrate installed the
|
|
789
|
+
// true answer is `none`. This is also the seam an out-of-process
|
|
790
|
+
// `xeno-use` adapter plugs into later: supplying a source here changes
|
|
791
|
+
// every surface at once, and changes nothing else.
|
|
792
|
+
// ๐ด The SAME object that chose the executor. Constructing a bare
|
|
793
|
+
// `RunIsolationService()` here โ as this line did โ meant the report was
|
|
794
|
+
// computed from nothing while execution was decided elsewhere, which is
|
|
795
|
+
// precisely the drift the environment exists to make unrepresentable.
|
|
796
|
+
runIsolationService: new RunIsolationService({ source: runExecutionEnvironment }),
|
|
797
|
+
subagentRuns,
|
|
798
|
+
acpApprovalService: createAcpApprovalPortAdapter({
|
|
799
|
+
registry,
|
|
800
|
+
resolveConfig: (agentId) => registry.getAgentInternal(agentId),
|
|
801
|
+
}),
|
|
802
|
+
turnExecutionAdapters,
|
|
803
|
+
});
|
|
804
|
+
// Anything that subscribed before this point is attached now. Without this the subagent
|
|
805
|
+
// adapter's terminal-event listener stays in the pending set forever.
|
|
806
|
+
attachPendingHostSubscribers();
|
|
807
|
+
return coordinator;
|
|
808
|
+
};
|
|
809
|
+
let disposed = false;
|
|
810
|
+
const dispose = async () => {
|
|
811
|
+
if (disposed)
|
|
812
|
+
return;
|
|
813
|
+
disposed = true;
|
|
814
|
+
// Observation sandboxes go first: a container that outlives the process
|
|
815
|
+
// that spawned it is a leak nothing later will clean up, and the detached
|
|
816
|
+
// host had no owner for these at all until they moved here.
|
|
817
|
+
await runObserver?.stopAll();
|
|
818
|
+
// `stateRepository` is undefined when this composition never won the lease
|
|
819
|
+
// and so never created a coordinator โ the ordinary case for a second app
|
|
820
|
+
// process. `close` is separately OPTIONAL on the port (the JSON
|
|
821
|
+
// implementation holds nothing), so both marks are load-bearing and neither
|
|
822
|
+
// is a defensive reflex.
|
|
823
|
+
await stateRepository?.close?.();
|
|
824
|
+
};
|
|
825
|
+
return {
|
|
826
|
+
createCoordinator,
|
|
827
|
+
paths,
|
|
828
|
+
registry,
|
|
829
|
+
runtimeStore,
|
|
830
|
+
workspaceAuthority,
|
|
831
|
+
workspaceShellService,
|
|
832
|
+
engineeringControlService,
|
|
833
|
+
runObservationService,
|
|
834
|
+
runObserver,
|
|
835
|
+
declaredUnavailable,
|
|
836
|
+
turnExecutionAdapterIds: turnExecutionAdapters.map((adapter) => adapter.id),
|
|
837
|
+
dispose,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* The provider catalog.
|
|
842
|
+
*
|
|
843
|
+
* `sdkRunnable` is a parameter rather than being derived here, because whether
|
|
844
|
+
* the lane can run is a fact about the SURFACE, and this function only knows
|
|
845
|
+
* about credentials. Deriving it would make a headless host advertise a lane it
|
|
846
|
+
* cannot execute โ the capability-honesty failure (ยง7.1) in its plainest form.
|
|
847
|
+
*/
|
|
848
|
+
export async function loadAgentHostProviders(registry, cloudOptions, options) {
|
|
849
|
+
const [acpProviders, cloudDiscovery] = await Promise.all([
|
|
850
|
+
createAcpRuntimeProviderDescriptors(registry),
|
|
851
|
+
discoverXenoCloudNodeRuntime(cloudOptions),
|
|
852
|
+
]);
|
|
853
|
+
const hostedStatus = cloudDiscovery.hostedStatus;
|
|
854
|
+
const models = toXenoCloudAgentModelDescriptors(cloudDiscovery.modelCatalog.models);
|
|
855
|
+
const sdkUnavailableReason = sdkUnavailableReasonFor({
|
|
856
|
+
sdkRunnable: options.sdkRunnable,
|
|
857
|
+
directCredentialAvailable: cloudDiscovery.directCredentialAvailable,
|
|
858
|
+
modelCatalogError: cloudDiscovery.modelCatalog.error,
|
|
859
|
+
});
|
|
860
|
+
const sdkProvider = createSdkNativeRuntimeProviderDescriptor({
|
|
861
|
+
models,
|
|
862
|
+
runnable: options.sdkRunnable && cloudDiscovery.directCredentialAvailable,
|
|
863
|
+
// So the `context.inject` capability a client reads describes the store this host actually
|
|
864
|
+
// composed, rather than a sentence written when only one store existed.
|
|
865
|
+
durablePendingInjections: options.durablePendingInjections === true,
|
|
866
|
+
...(sdkUnavailableReason ? { safeUnavailableReason: sdkUnavailableReason } : {}),
|
|
867
|
+
});
|
|
868
|
+
const safeUnavailableReason = hostedStatus.safeUnavailableReason || cloudDiscovery.modelCatalog.error;
|
|
869
|
+
const cloudProvider = createXenoCloudRuntimeProviderDescriptor({
|
|
870
|
+
authenticated: hostedStatus.authenticated && hostedStatus.ok,
|
|
871
|
+
controlCapabilities: hostedStatus.capabilities,
|
|
872
|
+
models,
|
|
873
|
+
...(safeUnavailableReason ? { safeUnavailableReason } : {}),
|
|
874
|
+
});
|
|
875
|
+
return [sdkProvider, cloudProvider, ...acpProviders];
|
|
876
|
+
}
|
|
877
|
+
//# sourceMappingURL=composeHost.js.map
|