@moxxy/core 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -0
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -13
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +138 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +4 -4
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +17 -0
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -30
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +125 -2
- package/src/subagents/run-child.ts +150 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import type { ModeContext, ModeDef, MoxxyEvent, ProviderDef } from '@moxxy/sdk';
|
|
3
3
|
import { defineMode, defineProvider, definePlugin } from '@moxxy/sdk';
|
|
4
4
|
import { Session } from '../session.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
continueChildTurn,
|
|
7
|
+
createSubagentSpawner,
|
|
8
|
+
runChildTurn,
|
|
9
|
+
type SubagentRuntime,
|
|
10
|
+
} from './run-child.js';
|
|
11
|
+
import { clearRetainedChildren } from './registry.js';
|
|
6
12
|
|
|
7
13
|
// A mode that reports the model it was handed — lets tests observe the
|
|
8
14
|
// child's EFFECTIVE model through the returned result text.
|
|
@@ -183,3 +189,120 @@ describe('runChildTurn model resolution', () => {
|
|
|
183
189
|
expect(result.text).toBe('cheap-model');
|
|
184
190
|
});
|
|
185
191
|
});
|
|
192
|
+
|
|
193
|
+
// A mode that runs instantly on its FIRST invocation but blocks on an external
|
|
194
|
+
// gate on every later turn — lets a test hold a continue() turn mid-flight while
|
|
195
|
+
// a second continue() races it (without hanging the initial spawn).
|
|
196
|
+
function makeGatedMode(gate: Promise<void>): ModeDef {
|
|
197
|
+
let calls = 0;
|
|
198
|
+
return defineMode({
|
|
199
|
+
name: 'gated',
|
|
200
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
201
|
+
calls += 1;
|
|
202
|
+
if (calls > 1) await gate;
|
|
203
|
+
await ctx.emit({
|
|
204
|
+
type: 'assistant_message',
|
|
205
|
+
sessionId: ctx.sessionId,
|
|
206
|
+
turnId: ctx.turnId,
|
|
207
|
+
source: 'assistant',
|
|
208
|
+
content: 'done',
|
|
209
|
+
stopReason: 'end_turn',
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
describe('spawnAll per-child degradation', () => {
|
|
216
|
+
it('isolates one child setup throw — siblings still resolve, batch never rejects', async () => {
|
|
217
|
+
const session = buildSession(['parent-model']);
|
|
218
|
+
const rt = buildRuntime(session, 'parent-model');
|
|
219
|
+
const spawner = createSubagentSpawner(rt);
|
|
220
|
+
|
|
221
|
+
// Force a PRE-`try` setup throw for exactly the SECOND child: buildChildContext
|
|
222
|
+
// calls appContext() for every child, so throw on its second invocation. With
|
|
223
|
+
// Promise.all this would reject the whole batch and orphan the first (still
|
|
224
|
+
// running) child's promise → an unhandledRejection; with allSettled it must
|
|
225
|
+
// degrade to one error-bearing result while the sibling succeeds.
|
|
226
|
+
const realAppContext = session.appContext.bind(session);
|
|
227
|
+
let appCtxCalls = 0;
|
|
228
|
+
vi.spyOn(session, 'appContext').mockImplementation(() => {
|
|
229
|
+
appCtxCalls += 1;
|
|
230
|
+
if (appCtxCalls === 2) throw new Error('boom: provider config gone');
|
|
231
|
+
return realAppContext();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const results = await spawner.spawnAll([
|
|
235
|
+
{ prompt: 'echo', mode: 'echo-model', label: 'good' },
|
|
236
|
+
{ prompt: 'echo', mode: 'echo-model', label: 'doomed' },
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
// The batch resolves (never rejects) with one result PER spec, in input order.
|
|
240
|
+
expect(results).toHaveLength(2);
|
|
241
|
+
expect(results.map((r) => r.label)).toEqual(['good', 'doomed']);
|
|
242
|
+
// Exactly one child degraded to an error-bearing result; the sibling succeeded.
|
|
243
|
+
const errored = results.filter((r) => r.stopReason === 'error');
|
|
244
|
+
const ok = results.filter((r) => r.stopReason === 'end_turn');
|
|
245
|
+
expect(errored).toHaveLength(1);
|
|
246
|
+
expect(ok).toHaveLength(1);
|
|
247
|
+
expect(errored[0]!.error?.message).toContain('boom');
|
|
248
|
+
expect(ok[0]!.text).toBe('parent-model');
|
|
249
|
+
vi.restoreAllMocks();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('returns one result per spec in input order on the all-success path', async () => {
|
|
253
|
+
const session = buildSession(['parent-model']);
|
|
254
|
+
const rt = buildRuntime(session, 'parent-model');
|
|
255
|
+
const spawner = createSubagentSpawner(rt);
|
|
256
|
+
|
|
257
|
+
const results = await spawner.spawnAll([
|
|
258
|
+
{ prompt: 'a', mode: 'echo-model', label: 'one' },
|
|
259
|
+
{ prompt: 'b', mode: 'echo-model', label: 'two' },
|
|
260
|
+
{ prompt: 'c', mode: 'echo-model', label: 'three' },
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
|
|
264
|
+
expect(results.every((r) => r.stopReason === 'end_turn')).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('continueChildTurn re-entrancy', () => {
|
|
269
|
+
it('rejects a concurrent continue() for the same retained child (claim-then-run)', async () => {
|
|
270
|
+
let openGate!: () => void;
|
|
271
|
+
const gate = new Promise<void>((resolve) => {
|
|
272
|
+
openGate = resolve;
|
|
273
|
+
});
|
|
274
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
275
|
+
session.pluginHost.registerStatic(
|
|
276
|
+
definePlugin({
|
|
277
|
+
name: 'gated-test',
|
|
278
|
+
version: '0.0.0',
|
|
279
|
+
providers: [makeProvider(['m'])],
|
|
280
|
+
modes: [makeGatedMode(gate)],
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
session.providers.setActive('listed');
|
|
284
|
+
session.modes.setActive('gated');
|
|
285
|
+
|
|
286
|
+
const rt = buildRuntime(session, 'm');
|
|
287
|
+
const first = await runChildTurn({
|
|
288
|
+
rt,
|
|
289
|
+
spec: { prompt: 'go', mode: 'gated', retainSession: true },
|
|
290
|
+
retainSession: true,
|
|
291
|
+
});
|
|
292
|
+
const childId = first.childSessionId;
|
|
293
|
+
|
|
294
|
+
// Start the first continue (blocks inside the gated mode), then race a
|
|
295
|
+
// second continue for the same id before the first settles.
|
|
296
|
+
const p1 = continueChildTurn({ childSessionId: childId, prompt: 'again' });
|
|
297
|
+
// The entry is claimed synchronously, so the second continue must fail fast.
|
|
298
|
+
await expect(
|
|
299
|
+
continueChildTurn({ childSessionId: childId, prompt: 'race' }),
|
|
300
|
+
).rejects.toThrow(/no retained subagent session/);
|
|
301
|
+
|
|
302
|
+
openGate();
|
|
303
|
+
const r1 = await p1;
|
|
304
|
+
expect(r1.text).toBe('done');
|
|
305
|
+
|
|
306
|
+
clearRetainedChildren(session.id);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -46,9 +46,10 @@ import {
|
|
|
46
46
|
} from './events.js';
|
|
47
47
|
import { buildFilteredToolRegistry } from './tools.js';
|
|
48
48
|
import {
|
|
49
|
-
|
|
49
|
+
claimRetainedChild,
|
|
50
50
|
registerRetainedChild,
|
|
51
51
|
releaseRetainedChild,
|
|
52
|
+
unclaimRetainedChild,
|
|
52
53
|
type RetainedChildSession,
|
|
53
54
|
} from './registry.js';
|
|
54
55
|
|
|
@@ -86,10 +87,13 @@ export async function runChildTurn(args: {
|
|
|
86
87
|
if ('failure' in resolved) return resolved.failure;
|
|
87
88
|
const { strategy, strategyName } = resolved;
|
|
88
89
|
|
|
90
|
+
// `undefined` means "inherit the full parent registry"; a present-but-empty
|
|
91
|
+
// array means "deny all" (least-privilege). Collapsing the two would turn an
|
|
92
|
+
// explicit [] into full tool inheritance — the opposite of the caller's intent.
|
|
89
93
|
const toolRegistry: ToolRegistry =
|
|
90
|
-
spec.allowedTools
|
|
91
|
-
?
|
|
92
|
-
: parentSession.tools;
|
|
94
|
+
spec.allowedTools === undefined
|
|
95
|
+
? parentSession.tools
|
|
96
|
+
: buildFilteredToolRegistry(parentSession.tools, new Set(spec.allowedTools));
|
|
93
97
|
|
|
94
98
|
const childModel = await resolveChildModel(rt, spec, label, childSessionId);
|
|
95
99
|
|
|
@@ -122,7 +126,7 @@ export async function runChildTurn(args: {
|
|
|
122
126
|
});
|
|
123
127
|
|
|
124
128
|
if (retainSession) {
|
|
125
|
-
registerRetainedChild({
|
|
129
|
+
const evicted = registerRetainedChild({
|
|
126
130
|
label,
|
|
127
131
|
childSessionId,
|
|
128
132
|
childTurnId,
|
|
@@ -133,53 +137,98 @@ export async function runChildTurn(args: {
|
|
|
133
137
|
strategyName,
|
|
134
138
|
parentSession,
|
|
135
139
|
parentTurnId,
|
|
140
|
+
tokensUsed: capture.tokensUsed,
|
|
136
141
|
});
|
|
142
|
+
// A capped/TTL-expired paused child is now unreachable: its `continue()`
|
|
143
|
+
// will fail. Surface that on the EVICTED child's own parent log (best-effort
|
|
144
|
+
// — a warning append must never take this spawn down) so the operator who
|
|
145
|
+
// started it isn't left waiting on a resume that can no longer happen.
|
|
146
|
+
await warnEvicted(evicted);
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
return capture.result;
|
|
140
150
|
}
|
|
141
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Emit a `subagent_warning` on each evicted child's OWN parent log so the
|
|
154
|
+
* operator who spawned a now-unreachable paused child sees why its `continue()`
|
|
155
|
+
* will fail. Best-effort: a warning-append reject (or a since-closed parent
|
|
156
|
+
* session) must never propagate out of the spawn that triggered the eviction.
|
|
157
|
+
*/
|
|
158
|
+
async function warnEvicted(evicted: ReadonlyArray<RetainedChildSession>): Promise<void> {
|
|
159
|
+
for (const e of evicted) {
|
|
160
|
+
try {
|
|
161
|
+
await emitSubagentWarning(
|
|
162
|
+
e.parentSession,
|
|
163
|
+
e.parentTurnId,
|
|
164
|
+
e.label,
|
|
165
|
+
e.childSessionId,
|
|
166
|
+
`retained subagent "${e.label}" was evicted before resume ` +
|
|
167
|
+
`(retention cap or TTL reached); its continue() will no longer work`,
|
|
168
|
+
);
|
|
169
|
+
} catch {
|
|
170
|
+
// A closed/failing parent log must not abort the spawn that evicted it.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
export async function continueChildTurn(args: {
|
|
143
176
|
childSessionId: SessionId;
|
|
144
177
|
prompt: string;
|
|
145
178
|
label?: string;
|
|
146
179
|
}): Promise<SubagentResult> {
|
|
147
|
-
|
|
180
|
+
// Claim-then-run: atomically remove the entry from the registry and mark it
|
|
181
|
+
// busy so a racing continue()/release() for the same id can't observe the
|
|
182
|
+
// live entry and drive strategy.run over the same childLog/childCtx in
|
|
183
|
+
// parallel (interleaved appends, double-seeded prompts, double release).
|
|
184
|
+
const retained = claimRetainedChild(args.childSessionId);
|
|
148
185
|
if (!retained) {
|
|
149
186
|
throw new Error(`no retained subagent session for "${String(args.childSessionId)}"`);
|
|
150
187
|
}
|
|
151
188
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
189
|
+
try {
|
|
190
|
+
await retained.childLog.append({
|
|
191
|
+
type: 'user_prompt',
|
|
192
|
+
sessionId: retained.childSessionId,
|
|
193
|
+
turnId: retained.childTurnId,
|
|
194
|
+
source: 'user',
|
|
195
|
+
text: args.prompt,
|
|
196
|
+
});
|
|
159
197
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
198
|
+
const rt: SubagentRuntime = {
|
|
199
|
+
parentSession: retained.parentSession,
|
|
200
|
+
parentTurnId: retained.parentTurnId,
|
|
201
|
+
// Re-derive the resume signal from the still-live owning session rather
|
|
202
|
+
// than reusing the possibly-already-aborted per-turn signal captured at
|
|
203
|
+
// first-turn spawn time (which would cancel the resume before any work).
|
|
204
|
+
parentSignal: retained.parentSession.signal,
|
|
205
|
+
parentModel: retained.childCtx.model,
|
|
206
|
+
};
|
|
166
207
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
const capture = await executeChildLoop({
|
|
209
|
+
rt,
|
|
210
|
+
spec: retained.spec,
|
|
211
|
+
label: args.label ?? retained.label,
|
|
212
|
+
childSessionId: retained.childSessionId,
|
|
213
|
+
childTurnId: retained.childTurnId,
|
|
214
|
+
childLog: retained.childLog,
|
|
215
|
+
childCtx: retained.childCtx,
|
|
216
|
+
strategy: retained.strategy,
|
|
217
|
+
strategyName: retained.strategyName,
|
|
218
|
+
emitCompleted: true,
|
|
219
|
+
skipStartEvent: true,
|
|
220
|
+
// Carry forward the cost accumulated across prior turns so the deferred
|
|
221
|
+
// subagent_completed reports the retained session's cumulative usage, not
|
|
222
|
+
// just this last continue's delta.
|
|
223
|
+
priorTokensUsed: retained.tokensUsed ?? 0,
|
|
224
|
+
});
|
|
180
225
|
|
|
181
|
-
|
|
182
|
-
|
|
226
|
+
return capture.result;
|
|
227
|
+
} finally {
|
|
228
|
+
// The entry was already removed by the claim; just drop the busy marker so
|
|
229
|
+
// a future continue() (if the session were re-registered) isn't blocked.
|
|
230
|
+
unclaimRetainedChild(args.childSessionId);
|
|
231
|
+
}
|
|
183
232
|
}
|
|
184
233
|
|
|
185
234
|
async function executeChildLoop(args: {
|
|
@@ -194,7 +243,8 @@ async function executeChildLoop(args: {
|
|
|
194
243
|
strategyName: string;
|
|
195
244
|
emitCompleted: boolean;
|
|
196
245
|
skipStartEvent?: boolean;
|
|
197
|
-
|
|
246
|
+
priorTokensUsed?: number;
|
|
247
|
+
}): Promise<{ result: SubagentResult; tokensUsed: number }> {
|
|
198
248
|
const {
|
|
199
249
|
rt,
|
|
200
250
|
spec,
|
|
@@ -207,6 +257,7 @@ async function executeChildLoop(args: {
|
|
|
207
257
|
strategyName,
|
|
208
258
|
emitCompleted,
|
|
209
259
|
skipStartEvent,
|
|
260
|
+
priorTokensUsed = 0,
|
|
210
261
|
} = args;
|
|
211
262
|
const { parentSession, parentTurnId } = rt;
|
|
212
263
|
|
|
@@ -214,7 +265,7 @@ async function executeChildLoop(args: {
|
|
|
214
265
|
text: '',
|
|
215
266
|
stopReason: 'end_turn' as StopReason,
|
|
216
267
|
error: null as string | null,
|
|
217
|
-
tokensUsed:
|
|
268
|
+
tokensUsed: priorTokensUsed,
|
|
218
269
|
};
|
|
219
270
|
|
|
220
271
|
const unsubCapture = childLog.subscribe((e) => {
|
|
@@ -280,7 +331,7 @@ async function executeChildLoop(args: {
|
|
|
280
331
|
);
|
|
281
332
|
}
|
|
282
333
|
|
|
283
|
-
return { result };
|
|
334
|
+
return { result, tokensUsed: capture.tokensUsed };
|
|
284
335
|
}
|
|
285
336
|
|
|
286
337
|
async function resolveStrategy(
|
|
@@ -296,23 +347,26 @@ async function resolveStrategy(
|
|
|
296
347
|
const exact = parentSession.modes.list().find((s) => s.name === requestedStrategy);
|
|
297
348
|
if (exact) return { strategy: exact, strategyName: requestedStrategy };
|
|
298
349
|
|
|
299
|
-
// Fall back
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
350
|
+
// Fall back when the model invented a name (e.g. "react"). Failing the child
|
|
351
|
+
// outright wastes the user's turn — any reasonable agent task can run on the
|
|
352
|
+
// session's current strategy. Prefer a mode literally named "default", then
|
|
353
|
+
// the session's active mode (a host may ship a renamed/alternative default),
|
|
354
|
+
// surfacing the fallback as a non-fatal warning so the operator sees it.
|
|
355
|
+
const fallback =
|
|
356
|
+
parentSession.modes.list().find((s) => s.name === 'default') ??
|
|
357
|
+
safeActiveMode(parentSession);
|
|
304
358
|
if (fallback) {
|
|
305
359
|
await emitSubagentWarning(
|
|
306
360
|
parentSession,
|
|
307
361
|
parentTurnId,
|
|
308
362
|
label,
|
|
309
363
|
childSessionId,
|
|
310
|
-
`unknown mode "${requestedStrategy}" — falling back to "
|
|
364
|
+
`unknown mode "${requestedStrategy}" — falling back to "${fallback.name}"`,
|
|
311
365
|
);
|
|
312
|
-
return { strategy: fallback, strategyName:
|
|
366
|
+
return { strategy: fallback, strategyName: fallback.name };
|
|
313
367
|
}
|
|
314
368
|
|
|
315
|
-
// No
|
|
369
|
+
// No fallback mode at all — that's a config error, not a model mistake.
|
|
316
370
|
await emitSubagentStart(parentSession, parentTurnId, label, childSessionId, spec, requestedStrategy);
|
|
317
371
|
const errorMsg = `Subagent failed: unknown mode "${requestedStrategy}" and no fallback available`;
|
|
318
372
|
await emitSubagentCompleted(parentSession, parentTurnId, label, childSessionId, '', 'error', errorMsg, spec.agentType ?? 'default', 0);
|
|
@@ -327,6 +381,17 @@ async function resolveStrategy(
|
|
|
327
381
|
};
|
|
328
382
|
}
|
|
329
383
|
|
|
384
|
+
/** `modes.getActive()` throws when no mode is active; treat that as "no fallback". */
|
|
385
|
+
function safeActiveMode(
|
|
386
|
+
parentSession: SessionRuntime,
|
|
387
|
+
): ReturnType<SessionRuntime['modes']['list']>[number] | undefined {
|
|
388
|
+
try {
|
|
389
|
+
return parentSession.modes.getActive();
|
|
390
|
+
} catch {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
330
395
|
/**
|
|
331
396
|
* Resolve the child's effective model. A `spec.model` usually comes from the
|
|
332
397
|
* calling LLM (dispatch_agent's free-form string field), which sometimes
|
|
@@ -405,15 +470,53 @@ function buildChildContext(
|
|
|
405
470
|
};
|
|
406
471
|
}
|
|
407
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Build an error-bearing {@link SubagentResult} for a spec whose `runChildTurn`
|
|
475
|
+
* threw during setup (before it could degrade the error into a result itself).
|
|
476
|
+
* Keeps `spawnAll` total: one result per spec, in input order, never a thrown
|
|
477
|
+
* batch. The real child id is unknown here (setup failed before it was captured),
|
|
478
|
+
* so a fresh placeholder id is minted; the result still carries the failing
|
|
479
|
+
* spec's label and the error message.
|
|
480
|
+
*/
|
|
481
|
+
function spawnFailureResult(spec: SubagentSpec, reason: unknown): SubagentResult {
|
|
482
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
483
|
+
return {
|
|
484
|
+
label: spec.label ?? 'subagent',
|
|
485
|
+
childSessionId: newSessionId(),
|
|
486
|
+
text: '',
|
|
487
|
+
stopReason: 'error' as StopReason,
|
|
488
|
+
error: { message },
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
408
492
|
export function createSubagentSpawner(rt: SubagentRuntime): SubagentSpawner {
|
|
409
493
|
return {
|
|
410
494
|
async spawn(spec) {
|
|
411
495
|
return runChildTurn({ rt, spec, retainSession: spec.retainSession === true });
|
|
412
496
|
},
|
|
413
497
|
async spawnAll(specs) {
|
|
414
|
-
|
|
415
|
-
|
|
498
|
+
// Per-child degradation: a single child's PRE-`try` setup throw (no active
|
|
499
|
+
// provider, a `compactors.getActive()` config error, a log-append reject in
|
|
500
|
+
// `resolveStrategy`) must NOT reject the whole batch. `Promise.all` would do
|
|
501
|
+
// exactly that, and worse — it would orphan the still-running sibling
|
|
502
|
+
// promises, whose later settlement surfaces as an `unhandledRejection`.
|
|
503
|
+
// Convert every spec into one result in input order; a setup throw becomes
|
|
504
|
+
// an error-bearing `SubagentResult` so a fan-out partially succeeds instead
|
|
505
|
+
// of taking the parent turn down with it.
|
|
506
|
+
const settled = await Promise.all(
|
|
507
|
+
specs.map(async (spec): Promise<SubagentResult> => {
|
|
508
|
+
try {
|
|
509
|
+
return await runChildTurn({ rt, spec, retainSession: spec.retainSession === true });
|
|
510
|
+
} catch (err) {
|
|
511
|
+
// A PRE-`try` setup throw inside runChildTurn (no active provider, a
|
|
512
|
+
// failing getActive(), a log-append reject in resolveStrategy) is
|
|
513
|
+
// caught HERE per-child so it can't reject the batch or orphan a
|
|
514
|
+
// sibling. The outer Promise.all is now safe: every element resolves.
|
|
515
|
+
return spawnFailureResult(spec, err);
|
|
516
|
+
}
|
|
517
|
+
}),
|
|
416
518
|
);
|
|
519
|
+
return settled;
|
|
417
520
|
},
|
|
418
521
|
async continue(args: SubagentContinueArgs) {
|
|
419
522
|
return continueChildTurn(args);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
defineSurface,
|
|
4
4
|
type SurfaceDataMessage,
|
|
@@ -93,6 +93,76 @@ describe('SurfaceHostImpl viewer ref-counting', () => {
|
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
describe('SurfaceHostImpl shutdown leak/hang hardening', () => {
|
|
97
|
+
it('closeAll closes an open() that resolves AFTER teardown (no leaked instance)', async () => {
|
|
98
|
+
// The open()'s def.open promise resolves only after closeAll has run; the
|
|
99
|
+
// freshly-created instance must be torn down, not installed as an orphan.
|
|
100
|
+
let resolveOpen!: () => void;
|
|
101
|
+
const gate = new Promise<void>((r) => {
|
|
102
|
+
resolveOpen = r;
|
|
103
|
+
});
|
|
104
|
+
const state = { closed: 0, instances: [] as SurfaceInstance[] };
|
|
105
|
+
const def = defineSurface({
|
|
106
|
+
kind: 'terminal',
|
|
107
|
+
open: async () => {
|
|
108
|
+
await gate;
|
|
109
|
+
const instance: SurfaceInstance = {
|
|
110
|
+
id: 'terminal',
|
|
111
|
+
kind: 'terminal',
|
|
112
|
+
onData: () => () => {},
|
|
113
|
+
input: () => {},
|
|
114
|
+
close: () => {
|
|
115
|
+
state.closed += 1;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
state.instances.push(instance);
|
|
119
|
+
return instance;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const host = makeHost(def);
|
|
123
|
+
|
|
124
|
+
const openPromise = host.open('terminal'); // in-flight, parked on the gate
|
|
125
|
+
const closeAll = host.closeAll(); // sets disposed; awaits the in-flight open
|
|
126
|
+
resolveOpen(); // open resolves only now, after teardown began
|
|
127
|
+
await expect(openPromise).rejects.toThrow(/has been closed/);
|
|
128
|
+
await closeAll;
|
|
129
|
+
|
|
130
|
+
// The instance was created — and immediately closed, never installed.
|
|
131
|
+
expect(state.instances).toHaveLength(1);
|
|
132
|
+
expect(state.closed).toBe(1);
|
|
133
|
+
// A stray open() after shutdown is rejected before creating anything.
|
|
134
|
+
await expect(host.open('terminal')).rejects.toThrow(/has been closed/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('a wedged instance.close() does not hang closeAll forever', async () => {
|
|
138
|
+
vi.useFakeTimers();
|
|
139
|
+
try {
|
|
140
|
+
const state = { closed: 0 };
|
|
141
|
+
const def = defineSurface({
|
|
142
|
+
kind: 'terminal',
|
|
143
|
+
open: () => ({
|
|
144
|
+
id: 'terminal',
|
|
145
|
+
kind: 'terminal',
|
|
146
|
+
onData: () => () => {},
|
|
147
|
+
input: () => {},
|
|
148
|
+
// Never resolves: a wedged PTY/browser teardown.
|
|
149
|
+
close: () => new Promise<void>(() => {}),
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const host = makeHost(def);
|
|
153
|
+
await host.open('terminal');
|
|
154
|
+
|
|
155
|
+
const done = host.closeAll();
|
|
156
|
+
// Without the timeout this would never settle; advance past it.
|
|
157
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
158
|
+
await expect(done).resolves.toBeUndefined();
|
|
159
|
+
expect(state.closed).toBe(0); // close() never resolved, but we moved on
|
|
160
|
+
} finally {
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
96
166
|
/**
|
|
97
167
|
* A controllable surface whose instance lets the test PUSH frames (`emit`),
|
|
98
168
|
* record resize calls, and run unsubscribe on close — enough to assert the
|
package/src/surfaces/host.ts
CHANGED
|
@@ -25,6 +25,8 @@ import type { SurfaceRegistryImpl } from '../registries/surfaces.js';
|
|
|
25
25
|
* existing resource and gets its {@link SurfaceInstance.snapshot}.
|
|
26
26
|
*/
|
|
27
27
|
export class SurfaceHostImpl implements SurfaceHost {
|
|
28
|
+
/** Upper bound on a single instance.close() before we give up waiting on it. */
|
|
29
|
+
private static readonly CLOSE_TIMEOUT_MS = 5000;
|
|
28
30
|
/** Open instances, keyed by kind (one shared instance per kind). */
|
|
29
31
|
private readonly instances = new Map<SurfaceKind, SurfaceInstance>();
|
|
30
32
|
/** Per-instance unsubscribe from its onData, dropped on close. */
|
|
@@ -44,6 +46,8 @@ export class SurfaceHostImpl implements SurfaceHost {
|
|
|
44
46
|
* snapshot, but keystrokes/navigation silently vanish).
|
|
45
47
|
*/
|
|
46
48
|
private readonly refs = new Map<SurfaceKind, number>();
|
|
49
|
+
/** Set once closeAll() runs: rejects/ tears down any open() that races teardown. */
|
|
50
|
+
private disposed = false;
|
|
47
51
|
|
|
48
52
|
constructor(
|
|
49
53
|
private readonly registry: SurfaceRegistryImpl,
|
|
@@ -77,6 +81,7 @@ export class SurfaceHostImpl implements SurfaceHost {
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
async open(kind: SurfaceKind): Promise<OpenSurfaceResult> {
|
|
84
|
+
if (this.disposed) throw new Error('SurfaceHost has been closed');
|
|
80
85
|
const existing = this.instances.get(kind);
|
|
81
86
|
if (existing) {
|
|
82
87
|
this.retain(kind);
|
|
@@ -95,6 +100,12 @@ export class SurfaceHostImpl implements SurfaceHost {
|
|
|
95
100
|
|
|
96
101
|
const promise = (async (): Promise<OpenSurfaceResult> => {
|
|
97
102
|
const instance = await def.open(this.ctx);
|
|
103
|
+
// If teardown raced this create, never install the live resource — close
|
|
104
|
+
// it immediately so we don't leak a PTY/page/child process post-shutdown.
|
|
105
|
+
if (this.disposed) {
|
|
106
|
+
await this.safeClose(instance);
|
|
107
|
+
throw new Error('SurfaceHost has been closed');
|
|
108
|
+
}
|
|
98
109
|
this.instances.set(kind, instance);
|
|
99
110
|
this.refs.set(kind, 1);
|
|
100
111
|
// Re-emit this instance's frames as multiplexed SurfaceDataMessages.
|
|
@@ -138,13 +149,33 @@ export class SurfaceHostImpl implements SurfaceHost {
|
|
|
138
149
|
this.unsubs.get(surfaceId)?.();
|
|
139
150
|
this.unsubs.delete(surfaceId);
|
|
140
151
|
this.instances.delete(kind);
|
|
152
|
+
await this.safeClose(instance, surfaceId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tear an instance down without ever blocking forever: a wedged PTY/browser
|
|
157
|
+
* whose close() neither resolves nor rejects must not stall session shutdown.
|
|
158
|
+
* Races close() against a timeout and swallows (logs) any rejection.
|
|
159
|
+
*/
|
|
160
|
+
private async safeClose(instance: SurfaceInstance, surfaceId = instance.id): Promise<void> {
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
141
162
|
try {
|
|
142
|
-
await
|
|
163
|
+
await Promise.race([
|
|
164
|
+
Promise.resolve(instance.close()),
|
|
165
|
+
new Promise<void>((resolve) => {
|
|
166
|
+
timer = setTimeout(() => {
|
|
167
|
+
this.logger?.warn?.('SurfaceHost: close timed out', { surfaceId });
|
|
168
|
+
resolve();
|
|
169
|
+
}, SurfaceHostImpl.CLOSE_TIMEOUT_MS);
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
143
172
|
} catch (err) {
|
|
144
173
|
this.logger?.warn?.('SurfaceHost: close failed', {
|
|
145
174
|
surfaceId,
|
|
146
175
|
err: err instanceof Error ? err.message : String(err),
|
|
147
176
|
});
|
|
177
|
+
} finally {
|
|
178
|
+
if (timer) clearTimeout(timer);
|
|
148
179
|
}
|
|
149
180
|
}
|
|
150
181
|
|
|
@@ -168,9 +199,15 @@ export class SurfaceHostImpl implements SurfaceHost {
|
|
|
168
199
|
|
|
169
200
|
async closeAll(): Promise<void> {
|
|
170
201
|
// Session shutdown: tear every instance down regardless of viewer refs.
|
|
202
|
+
this.disposed = true;
|
|
203
|
+
// Drain in-flight opens first. With `disposed` set, each racing open closes
|
|
204
|
+
// its freshly-created instance itself and rejects, so nothing new lands in
|
|
205
|
+
// `instances` after this await — no leaked PTY/page/child process.
|
|
206
|
+
await Promise.allSettled([...this.opening.values()]);
|
|
171
207
|
for (const kind of [...this.instances.keys()]) this.refs.set(kind, 1);
|
|
208
|
+
// Run closes concurrently so one wedged instance can't block the others.
|
|
172
209
|
const ids = [...this.unsubs.keys()];
|
|
173
|
-
|
|
210
|
+
await Promise.allSettled(ids.map((id) => this.close(id)));
|
|
174
211
|
}
|
|
175
212
|
|
|
176
213
|
private byId(surfaceId: string): SurfaceInstance | undefined {
|