@moxxy/core 0.5.0 → 0.5.2
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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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 -19
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -49
- 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 +137 -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 +2 -2
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/index.ts +0 -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 +18 -39
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -68
- 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 +163 -2
- package/src/subagents/run-child.ts +151 -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
|
@@ -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,54 @@ 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, and must NEVER abort or
|
|
501
|
+
// orphan its still-running siblings (whose later settlement would otherwise
|
|
502
|
+
// surface as an `unhandledRejection`). `Promise.all` short-circuits on the
|
|
503
|
+
// first rejection and leaks the rest — `Promise.allSettled` always waits for
|
|
504
|
+
// every child and never rejects. Each spec yields exactly one result, in
|
|
505
|
+
// input order; a setup throw becomes an error-bearing `SubagentResult` so a
|
|
506
|
+
// fan-out partially succeeds instead of taking the parent turn down with it.
|
|
507
|
+
const outcomes = await Promise.allSettled(
|
|
508
|
+
specs.map((spec) =>
|
|
509
|
+
runChildTurn({ rt, spec, retainSession: spec.retainSession === true }),
|
|
510
|
+
),
|
|
416
511
|
);
|
|
512
|
+
return outcomes.map((outcome, i): SubagentResult => {
|
|
513
|
+
if (outcome.status === 'fulfilled') return outcome.value;
|
|
514
|
+
// `specs[i]` is always defined here (i indexes the same array we mapped),
|
|
515
|
+
// but `noUncheckedIndexedAccess` widens it to `| undefined`; narrow so the
|
|
516
|
+
// failure result still carries the failing spec's label rather than the
|
|
517
|
+
// generic fallback.
|
|
518
|
+
const spec = specs[i];
|
|
519
|
+
return spawnFailureResult(spec ?? { prompt: '' }, outcome.reason);
|
|
520
|
+
});
|
|
417
521
|
},
|
|
418
522
|
async continue(args: SubagentContinueArgs) {
|
|
419
523
|
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 {
|
package/src/usage-stats.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
@@ -74,4 +74,102 @@ describe('usage-stats store', () => {
|
|
|
74
74
|
await fs.writeFile(statsPath, '{ not json', 'utf8');
|
|
75
75
|
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it('rejects a shape-valid file with a non-numeric counter (no string-concat corruption)', async () => {
|
|
79
|
+
// A hand-edited / partially-written file where a counter is a string used to
|
|
80
|
+
// pass the old `typeof models === 'object'` cast and flow straight into
|
|
81
|
+
// addModelTotals, corrupting the aggregate via "100" + 50 = "10050".
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
statsPath,
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
version: 1,
|
|
86
|
+
updatedAt: new Date().toISOString(),
|
|
87
|
+
models: {
|
|
88
|
+
'anthropic/opus': {
|
|
89
|
+
calls: 1,
|
|
90
|
+
inputTokens: '100',
|
|
91
|
+
outputTokens: 0,
|
|
92
|
+
cacheReadTokens: 0,
|
|
93
|
+
cacheCreationTokens: 0,
|
|
94
|
+
firstSeen: new Date().toISOString(),
|
|
95
|
+
lastSeen: new Date().toISOString(),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
'utf8',
|
|
100
|
+
);
|
|
101
|
+
// Shape-invalid → fall through to empty, exactly like malformed JSON.
|
|
102
|
+
expect((await loadUsageStats(statsPath)).models).toEqual({});
|
|
103
|
+
|
|
104
|
+
// And a subsequent merge produces purely numeric totals (no concatenation).
|
|
105
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 50 }) }, statsPath);
|
|
106
|
+
const file = await loadUsageStats(statsPath);
|
|
107
|
+
expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
|
|
108
|
+
expect(typeof file.models['anthropic/opus']!.inputTokens).toBe('number');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('clear and merge serialize on the same mutex (clear cannot resurrect a cleared aggregate)', async () => {
|
|
112
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100 }) }, statsPath);
|
|
113
|
+
// Fire a clear and a merge concurrently. Whatever the interleaving, the two
|
|
114
|
+
// must serialize against each other so the file is never the stale
|
|
115
|
+
// pre-clear snapshot reconstituted by a merge that read before the clear.
|
|
116
|
+
// The only sound end-states are: empty (merge ran then clear), or exactly
|
|
117
|
+
// the merged delta (clear ran then merge) — never the resurrected old 100
|
|
118
|
+
// plus delta.
|
|
119
|
+
await Promise.all([
|
|
120
|
+
clearUsageStats(statsPath),
|
|
121
|
+
mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 7 }) }, statsPath),
|
|
122
|
+
]);
|
|
123
|
+
const file = await loadUsageStats(statsPath);
|
|
124
|
+
const entry = file.models['anthropic/opus'];
|
|
125
|
+
if (entry) {
|
|
126
|
+
// clear-then-merge ordering: only the post-clear delta survives.
|
|
127
|
+
expect(entry.inputTokens).toBe(7);
|
|
128
|
+
} else {
|
|
129
|
+
// merge-then-clear ordering: aggregate is empty.
|
|
130
|
+
expect(file.models).toEqual({});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('swallows a write failure on merge: logs to stderr, returns the merged file, never throws', async () => {
|
|
135
|
+
// mergeUsageStats runs on shutdown and is documented best-effort — losing one
|
|
136
|
+
// session's stats must never block shutdown. Force an unwritable target by
|
|
137
|
+
// pointing the file *under a regular file* so the atomic writer's
|
|
138
|
+
// `mkdir(dirname)` fails with ENOTDIR.
|
|
139
|
+
const blocker = path.join(tmpDir, 'blocker');
|
|
140
|
+
await fs.writeFile(blocker, 'x', 'utf8');
|
|
141
|
+
const unwritable = path.join(blocker, 'nested', 'usage.json');
|
|
142
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
143
|
+
try {
|
|
144
|
+
const result = await mergeUsageStats(
|
|
145
|
+
{ 'anthropic/opus': totals({ inputTokens: 5 }) },
|
|
146
|
+
unwritable,
|
|
147
|
+
);
|
|
148
|
+
// Resolves with the in-memory merged file (the caller still sees the delta),
|
|
149
|
+
// and surfaces the persist failure on stderr rather than throwing.
|
|
150
|
+
expect(result.models['anthropic/opus']!.inputTokens).toBe(5);
|
|
151
|
+
expect(stderr).toHaveBeenCalled();
|
|
152
|
+
} finally {
|
|
153
|
+
stderr.mockRestore();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('clearUsageStats rejects (loud) if it cannot write — a clear that did not persist must not look successful', async () => {
|
|
158
|
+
// Unlike merge, clear is an explicit user action (`/usage clear`); silently
|
|
159
|
+
// swallowing its failure would tell the user the aggregate was wiped when it
|
|
160
|
+
// was not. We pin the current behavior so a future refactor can't quietly
|
|
161
|
+
// turn it into a no-op success.
|
|
162
|
+
const blocker = path.join(tmpDir, 'clear-blocker');
|
|
163
|
+
await fs.writeFile(blocker, 'x', 'utf8');
|
|
164
|
+
const unwritable = path.join(blocker, 'nested', 'usage.json');
|
|
165
|
+
await expect(clearUsageStats(unwritable)).rejects.toBeInstanceOf(Error);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('coalesces a missing firstSeen on merge rather than emitting undefined', async () => {
|
|
169
|
+
// Defense in depth: even if an entry somehow lacks firstSeen, a merge must
|
|
170
|
+
// not propagate `firstSeen: undefined` forward.
|
|
171
|
+
await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 1 }) }, statsPath);
|
|
172
|
+
const file = await loadUsageStats(statsPath);
|
|
173
|
+
expect(file.models['anthropic/opus']!.firstSeen).toBeTruthy();
|
|
174
|
+
});
|
|
77
175
|
});
|