@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
package/src/skills/synthesize.ts
CHANGED
|
@@ -84,12 +84,11 @@ export async function synthesizeSkill(
|
|
|
84
84
|
}
|
|
85
85
|
const frontmatter = parsed.data as Skill['frontmatter'];
|
|
86
86
|
|
|
87
|
-
const finalPath = await
|
|
88
|
-
await fs.writeFile(finalPath, draft.raw, 'utf8');
|
|
87
|
+
const finalPath = await writeUniqueSkill(baseDir, slugify(frontmatter.name), draft.raw);
|
|
89
88
|
|
|
90
89
|
// Derive the skill id from the on-disk filename, not from the LLM-supplied
|
|
91
90
|
// frontmatter name — otherwise synthesizing the same name twice collides
|
|
92
|
-
// even when
|
|
91
|
+
// even when writeUniqueSkill has just bumped the filename to `<slug>-2.md`.
|
|
93
92
|
const basename = path.basename(finalPath, '.md');
|
|
94
93
|
const skill: Skill = {
|
|
95
94
|
id: asSkillId(`${scope}/${basename}`),
|
|
@@ -100,26 +99,46 @@ export async function synthesizeSkill(
|
|
|
100
99
|
};
|
|
101
100
|
session.skills.register(skill);
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
102
|
+
// The skill file on disk + the live registration are the product; the
|
|
103
|
+
// skill_created event and the audit JSONL line are telemetry. A failure in
|
|
104
|
+
// either (listener/persistence reject, EACCES on .meta, read-only fs) must
|
|
105
|
+
// NOT throw after the skill is already written + registered — that would
|
|
106
|
+
// surface an error the model retries, orphaning the just-created skill and
|
|
107
|
+
// duplicating it. Make both best-effort and warn instead.
|
|
108
|
+
try {
|
|
109
|
+
await session.log.append({
|
|
110
|
+
type: 'skill_created',
|
|
111
|
+
sessionId: session.id,
|
|
112
|
+
turnId: turnId ?? session.startTurn().turnId,
|
|
113
|
+
source: 'system',
|
|
114
|
+
skillId: skill.id,
|
|
115
|
+
name: skill.frontmatter.name,
|
|
116
|
+
path: finalPath,
|
|
117
|
+
scope,
|
|
118
|
+
originatingPrompt: intent,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
process.stderr.write(
|
|
122
|
+
`moxxy: synthesize_skill could not emit skill_created for "${skill.frontmatter.name}": ` +
|
|
123
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
114
126
|
|
|
115
127
|
const auditPath = opts.auditPath ?? path.join(defaultUserSkillsDir(), '.meta', 'created.jsonl');
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
try {
|
|
129
|
+
await appendAudit(auditPath, {
|
|
130
|
+
slug: path.basename(finalPath, '.md'),
|
|
131
|
+
ts: new Date().toISOString(),
|
|
132
|
+
sessionId: String(session.id),
|
|
133
|
+
originatingPrompt: intent,
|
|
134
|
+
scope,
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`moxxy: synthesize_skill audit append failed (${auditPath}): ` +
|
|
139
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
123
142
|
|
|
124
143
|
return { skill, path: finalPath, scope };
|
|
125
144
|
}
|
|
@@ -132,28 +151,62 @@ function slugify(name: string): string {
|
|
|
132
151
|
.slice(0, 60);
|
|
133
152
|
}
|
|
134
153
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Pick a unique `<base>[-N].md` name AND create it atomically with the `wx`
|
|
156
|
+
* flag (fail if exists). Name selection and creation are one step, so two
|
|
157
|
+
* concurrent synthesizeSkill calls (workflows / self-improver) racing on the
|
|
158
|
+
* same slug can't both pick the same candidate and have the later writeFile
|
|
159
|
+
* truncate the earlier skill — the loser gets EEXIST and bumps the suffix.
|
|
160
|
+
*/
|
|
161
|
+
async function writeUniqueSkill(dir: string, base: string, data: string): Promise<string> {
|
|
162
|
+
let n = 1;
|
|
163
|
+
// Bound the search so a directory already saturated with `<base>-*.md` (or a
|
|
164
|
+
// persistent EEXIST race) can't spin forever — surface a clear error instead.
|
|
165
|
+
const MAX_ATTEMPTS = 1000;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const candidate = path.join(dir, n === 1 ? `${base}.md` : `${base}-${n}.md`);
|
|
168
|
+
try {
|
|
169
|
+
await fs.writeFile(candidate, data, { encoding: 'utf8', flag: 'wx' });
|
|
170
|
+
return candidate;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
173
|
+
n += 1;
|
|
174
|
+
if (n > MAX_ATTEMPTS) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`synthesize_skill: could not find a free filename for "${base}" after ${MAX_ATTEMPTS} attempts`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
141
180
|
}
|
|
142
|
-
return candidate;
|
|
143
181
|
}
|
|
144
182
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await fs.access(p);
|
|
148
|
-
return true;
|
|
149
|
-
} catch {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
183
|
+
/** Cap the audit JSONL so a long-lived/self-synthesizing install can't grow it without bound. */
|
|
184
|
+
const MAX_AUDIT_LINES = 2000;
|
|
153
185
|
|
|
154
186
|
async function appendAudit(filePath: string, entry: Record<string, unknown>): Promise<void> {
|
|
155
187
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
156
188
|
await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf8');
|
|
189
|
+
await rotateAuditIfNeeded(filePath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Keep only the most recent {@link MAX_AUDIT_LINES} lines. Best-effort and
|
|
194
|
+
* non-atomic by design — this file is pure telemetry that nothing reads back
|
|
195
|
+
* with a bound, so a crash mid-rewrite at worst loses a few audit lines.
|
|
196
|
+
*/
|
|
197
|
+
async function rotateAuditIfNeeded(filePath: string): Promise<void> {
|
|
198
|
+
let raw: string;
|
|
199
|
+
try {
|
|
200
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
201
|
+
} catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const lines = raw.split('\n');
|
|
205
|
+
// Account for the trailing empty element from the final newline.
|
|
206
|
+
const nonEmpty = lines.filter((l) => l.length > 0);
|
|
207
|
+
if (nonEmpty.length <= MAX_AUDIT_LINES) return;
|
|
208
|
+
const kept = nonEmpty.slice(nonEmpty.length - MAX_AUDIT_LINES);
|
|
209
|
+
await fs.writeFile(filePath, kept.join('\n') + '\n', 'utf8');
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
export function buildSynthesizeSkillPlugin(
|
|
@@ -53,4 +53,60 @@ describe('streamChildEventToParent', () => {
|
|
|
53
53
|
expect(arg.type).toBe('plugin_event');
|
|
54
54
|
expect(arg.subtype).toBe('subagent_tool_call');
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
// A child Bash/Read returning a multi-MB blob must NOT be copied verbatim into
|
|
58
|
+
// the parent log (unbounded amplification): the forwarded output is truncated.
|
|
59
|
+
it('truncates an oversized child tool_result output before mirroring to the parent', async () => {
|
|
60
|
+
const append = vi.fn().mockResolvedValue(undefined);
|
|
61
|
+
const parent = {
|
|
62
|
+
id: asSessionId('parent'),
|
|
63
|
+
log: { append } as unknown as EventLog,
|
|
64
|
+
} as unknown as SessionRuntime;
|
|
65
|
+
|
|
66
|
+
const huge = 'x'.repeat(2 * 1024 * 1024); // 2 MiB
|
|
67
|
+
const bigResult = {
|
|
68
|
+
type: 'tool_result',
|
|
69
|
+
seq: 1,
|
|
70
|
+
id: 'e1',
|
|
71
|
+
ts: 0,
|
|
72
|
+
sessionId: asSessionId('child'),
|
|
73
|
+
turnId: asTurnId('ct'),
|
|
74
|
+
source: 'model',
|
|
75
|
+
callId: 'call-2',
|
|
76
|
+
ok: true,
|
|
77
|
+
output: huge,
|
|
78
|
+
} as unknown as MoxxyEvent;
|
|
79
|
+
|
|
80
|
+
await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), bigResult);
|
|
81
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
82
|
+
const arg = append.mock.calls[0]![0] as { payload: { output: string } };
|
|
83
|
+
expect(arg.payload.output.length).toBeLessThan(huge.length);
|
|
84
|
+
expect(arg.payload.output.length).toBeLessThan(64 * 1024);
|
|
85
|
+
expect(arg.payload.output).toContain('elided');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('passes a small child tool_result output through unchanged', async () => {
|
|
89
|
+
const append = vi.fn().mockResolvedValue(undefined);
|
|
90
|
+
const parent = {
|
|
91
|
+
id: asSessionId('parent'),
|
|
92
|
+
log: { append } as unknown as EventLog,
|
|
93
|
+
} as unknown as SessionRuntime;
|
|
94
|
+
|
|
95
|
+
const smallResult = {
|
|
96
|
+
type: 'tool_result',
|
|
97
|
+
seq: 1,
|
|
98
|
+
id: 'e1',
|
|
99
|
+
ts: 0,
|
|
100
|
+
sessionId: asSessionId('child'),
|
|
101
|
+
turnId: asTurnId('ct'),
|
|
102
|
+
source: 'model',
|
|
103
|
+
callId: 'call-3',
|
|
104
|
+
ok: true,
|
|
105
|
+
output: 'short output',
|
|
106
|
+
} as unknown as MoxxyEvent;
|
|
107
|
+
|
|
108
|
+
await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), smallResult);
|
|
109
|
+
const arg = append.mock.calls[0]![0] as { payload: { output: unknown } };
|
|
110
|
+
expect(arg.payload.output).toBe('short output');
|
|
111
|
+
});
|
|
56
112
|
});
|
package/src/subagents/events.ts
CHANGED
|
@@ -133,6 +133,32 @@ export async function streamChildEventToParent(
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Max chars of a forwarded child tool output/error mirrored onto the parent log. */
|
|
137
|
+
const MAX_FORWARD_CHARS = 16 * 1024;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Bound an arbitrary child tool result before mirroring it onto the parent log.
|
|
141
|
+
* Strings are truncated with an elision marker; other values pass through when
|
|
142
|
+
* their serialized length is small and are replaced with a marker when not (we
|
|
143
|
+
* avoid re-parsing — the full payload still lives in the child log).
|
|
144
|
+
*/
|
|
145
|
+
function truncateForward(value: unknown): unknown {
|
|
146
|
+
if (typeof value === 'string') {
|
|
147
|
+
return value.length > MAX_FORWARD_CHARS
|
|
148
|
+
? `${value.slice(0, MAX_FORWARD_CHARS)}… [${value.length - MAX_FORWARD_CHARS} more chars elided]`
|
|
149
|
+
: value;
|
|
150
|
+
}
|
|
151
|
+
if (value === null || value === undefined || typeof value !== 'object') return value;
|
|
152
|
+
let serialized: string;
|
|
153
|
+
try {
|
|
154
|
+
serialized = JSON.stringify(value);
|
|
155
|
+
} catch {
|
|
156
|
+
return '[unserializable subagent output elided]';
|
|
157
|
+
}
|
|
158
|
+
if (serialized.length <= MAX_FORWARD_CHARS) return value;
|
|
159
|
+
return `${serialized.slice(0, MAX_FORWARD_CHARS)}… [${serialized.length - MAX_FORWARD_CHARS} more chars elided]`;
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
function mapChildEvent(
|
|
137
163
|
label: string,
|
|
138
164
|
childSessionId: SessionId,
|
|
@@ -154,8 +180,12 @@ function mapChildEvent(
|
|
|
154
180
|
case 'tool_result':
|
|
155
181
|
payload.callId = String(childEvt.callId);
|
|
156
182
|
payload.ok = childEvt.ok;
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
// Child tool outputs are `unknown` and unbounded (a child Read/Bash can
|
|
184
|
+
// return multi-MB blobs). Forwarding them verbatim copies the full payload
|
|
185
|
+
// into the in-memory parent log too — doubling memory/persistence cost and
|
|
186
|
+
// multiplying it under deep nesting. The TUI only needs a preview, so cap it.
|
|
187
|
+
if (childEvt.ok) payload.output = truncateForward(childEvt.output);
|
|
188
|
+
else payload.error = truncateForward(childEvt.error);
|
|
159
189
|
return { subtype: 'subagent_tool_result', payload };
|
|
160
190
|
case 'error':
|
|
161
191
|
payload.kind = childEvt.kind;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { asSessionId } from '@moxxy/sdk';
|
|
3
|
+
import type { SessionRuntime } from '../session-runtime.js';
|
|
4
|
+
import {
|
|
5
|
+
claimRetainedChild,
|
|
6
|
+
clearRetainedChildren,
|
|
7
|
+
getRetainedChild,
|
|
8
|
+
registerRetainedChild,
|
|
9
|
+
releaseRetainedChild,
|
|
10
|
+
unclaimRetainedChild,
|
|
11
|
+
type RetainedChildSession,
|
|
12
|
+
} from './registry.js';
|
|
13
|
+
|
|
14
|
+
// Minimal retained entry — the registry only reads childSessionId, parentSession.id,
|
|
15
|
+
// and retainedAt; the rest can be cast through `unknown`.
|
|
16
|
+
function makeEntry(id: string, parentId = 'parent'): RetainedChildSession {
|
|
17
|
+
return {
|
|
18
|
+
childSessionId: asSessionId(id),
|
|
19
|
+
parentSession: { id: asSessionId(parentId) } as unknown as SessionRuntime,
|
|
20
|
+
} as unknown as RetainedChildSession;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
clearRetainedChildren();
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('retained-child registry hardening', () => {
|
|
29
|
+
it('claim removes the entry and marks it busy so a racing claim cannot double-drive it', () => {
|
|
30
|
+
const id = asSessionId('c1');
|
|
31
|
+
registerRetainedChild(makeEntry('c1'));
|
|
32
|
+
|
|
33
|
+
const first = claimRetainedChild(id);
|
|
34
|
+
expect(first).toBeDefined();
|
|
35
|
+
// Entry is gone from the registry the instant it is claimed.
|
|
36
|
+
expect(getRetainedChild(id)).toBeUndefined();
|
|
37
|
+
// A racing claim for the same id finds nothing (and is also blocked by busy).
|
|
38
|
+
expect(claimRetainedChild(id)).toBeUndefined();
|
|
39
|
+
|
|
40
|
+
unclaimRetainedChild(id);
|
|
41
|
+
expect(claimRetainedChild(id)).toBeUndefined(); // still gone from registry
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('release while a claim is in flight drops the busy marker without resurrecting the entry', () => {
|
|
45
|
+
const id = asSessionId('c2');
|
|
46
|
+
registerRetainedChild(makeEntry('c2'));
|
|
47
|
+
expect(claimRetainedChild(id)).toBeDefined();
|
|
48
|
+
releaseRetainedChild(id); // racing release()
|
|
49
|
+
// Re-registering then claiming works again (busy was cleared).
|
|
50
|
+
registerRetainedChild(makeEntry('c2'));
|
|
51
|
+
expect(claimRetainedChild(id)).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('caps the number of retained children, evicting the oldest', () => {
|
|
55
|
+
for (let i = 0; i < 100; i++) registerRetainedChild(makeEntry(`k${i}`));
|
|
56
|
+
// The earliest entries must have been evicted; the latest survive.
|
|
57
|
+
expect(getRetainedChild(asSessionId('k0'))).toBeUndefined();
|
|
58
|
+
expect(getRetainedChild(asSessionId('k99'))).toBeDefined();
|
|
59
|
+
// Bounded: never more than the cap.
|
|
60
|
+
let live = 0;
|
|
61
|
+
for (let i = 0; i < 100; i++) if (getRetainedChild(asSessionId(`k${i}`))) live++;
|
|
62
|
+
expect(live).toBeLessThanOrEqual(64);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns the entry evicted under the cap so the caller can warn the operator', () => {
|
|
66
|
+
// Fill to the cap, then one more — the registering call must hand back the
|
|
67
|
+
// oldest (k0) it dropped so run-child can emit a subagent_warning for it.
|
|
68
|
+
for (let i = 0; i < 64; i++) registerRetainedChild(makeEntry(`c${i}`));
|
|
69
|
+
const evicted = registerRetainedChild(makeEntry('overflow'));
|
|
70
|
+
expect(evicted.map((e) => String(e.childSessionId))).toContain('c0');
|
|
71
|
+
expect(getRetainedChild(asSessionId('c0'))).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns TTL-expired entries it pruned on the next register', () => {
|
|
75
|
+
vi.useFakeTimers();
|
|
76
|
+
vi.setSystemTime(0);
|
|
77
|
+
registerRetainedChild(makeEntry('aged'));
|
|
78
|
+
|
|
79
|
+
vi.setSystemTime(31 * 60 * 1000);
|
|
80
|
+
const evicted = registerRetainedChild(makeEntry('newcomer'));
|
|
81
|
+
expect(evicted.map((e) => String(e.childSessionId))).toContain('aged');
|
|
82
|
+
expect(getRetainedChild(asSessionId('aged'))).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('evicts a stale paused child after the TTL on the next register', () => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
vi.setSystemTime(0);
|
|
88
|
+
registerRetainedChild(makeEntry('stale'));
|
|
89
|
+
expect(getRetainedChild(asSessionId('stale'))).toBeDefined();
|
|
90
|
+
|
|
91
|
+
// Advance past the 30-minute TTL, then register a fresh child — that
|
|
92
|
+
// triggers pruning of the expired one.
|
|
93
|
+
vi.setSystemTime(31 * 60 * 1000);
|
|
94
|
+
registerRetainedChild(makeEntry('fresh'));
|
|
95
|
+
expect(getRetainedChild(asSessionId('stale'))).toBeUndefined();
|
|
96
|
+
expect(getRetainedChild(asSessionId('fresh'))).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('clearRetainedChildren scopes to the owning session', () => {
|
|
100
|
+
registerRetainedChild(makeEntry('a1', 'sessA'));
|
|
101
|
+
registerRetainedChild(makeEntry('b1', 'sessB'));
|
|
102
|
+
clearRetainedChildren(asSessionId('sessA'));
|
|
103
|
+
expect(getRetainedChild(asSessionId('a1'))).toBeUndefined();
|
|
104
|
+
expect(getRetainedChild(asSessionId('b1'))).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -28,20 +28,93 @@ export interface RetainedChildSession {
|
|
|
28
28
|
readonly strategyName: string;
|
|
29
29
|
readonly parentSession: SessionRuntime;
|
|
30
30
|
readonly parentTurnId: TurnId;
|
|
31
|
+
/** Cumulative provider token cost across this retained session's turns. */
|
|
32
|
+
tokensUsed?: number;
|
|
33
|
+
/** Wall-clock ms when the entry was (re)registered — drives TTL eviction. */
|
|
34
|
+
readonly retainedAt?: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Cap the number of live retained children. Each entry pins a full EventLog +
|
|
39
|
+
* ModeContext (provider, tool registry, plugin host, signal); an `awaitInput`
|
|
40
|
+
* child whose resume never arrives would otherwise leak for the life of a
|
|
41
|
+
* long-lived in-process runner. When the cap is exceeded the oldest entry is
|
|
42
|
+
* evicted (its `continue()` will then fail with "no retained subagent session").
|
|
43
|
+
*/
|
|
44
|
+
const MAX_RETAINED = 64;
|
|
45
|
+
/** Evict a paused child this long after registration if never resumed. */
|
|
46
|
+
const RETAIN_TTL_MS = 30 * 60 * 1000;
|
|
47
|
+
|
|
33
48
|
const retained = new Map<string, RetainedChildSession>();
|
|
49
|
+
/** Child session ids with a `continue()` currently in flight (claim-then-run). */
|
|
50
|
+
const busy = new Set<string>();
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
/** Drop entries past their TTL and return them so the caller can warn. */
|
|
53
|
+
function pruneExpired(now: number): RetainedChildSession[] {
|
|
54
|
+
const expired: RetainedChildSession[] = [];
|
|
55
|
+
for (const [id, entry] of retained) {
|
|
56
|
+
if (entry.retainedAt !== undefined && now - entry.retainedAt >= RETAIN_TTL_MS) {
|
|
57
|
+
retained.delete(id);
|
|
58
|
+
expired.push(entry);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return expired;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register (or re-register) a retained child. Returns the entries DROPPED by
|
|
66
|
+
* this call — TTL-expired children plus any evicted to make room under the cap —
|
|
67
|
+
* so the caller can surface a `subagent_warning` for each (a paused `awaitInput`
|
|
68
|
+
* child whose `continue()` will now fail with "no retained subagent session").
|
|
69
|
+
* The registry has no SessionRuntime handle of its own, so it can't emit.
|
|
70
|
+
*/
|
|
71
|
+
export function registerRetainedChild(session: RetainedChildSession): RetainedChildSession[] {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const evicted = pruneExpired(now);
|
|
74
|
+
const id = String(session.childSessionId);
|
|
75
|
+
// Map preserves insertion order, so the first key is the oldest. Evict
|
|
76
|
+
// oldest until we have room for this one (Map.set on an existing id is an
|
|
77
|
+
// update, not an insert, so don't count a re-register against the cap).
|
|
78
|
+
while (retained.size >= MAX_RETAINED && !retained.has(id)) {
|
|
79
|
+
const [oldestId, oldestEntry] = retained.entries().next().value ?? [];
|
|
80
|
+
if (oldestId === undefined) break;
|
|
81
|
+
retained.delete(oldestId);
|
|
82
|
+
if (oldestEntry) evicted.push(oldestEntry);
|
|
83
|
+
}
|
|
84
|
+
retained.set(id, { ...session, retainedAt: now });
|
|
85
|
+
return evicted;
|
|
37
86
|
}
|
|
38
87
|
|
|
39
88
|
export function getRetainedChild(childSessionId: SessionId): RetainedChildSession | undefined {
|
|
40
89
|
return retained.get(String(childSessionId));
|
|
41
90
|
}
|
|
42
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Atomically claim a retained child for a `continue()` turn: remove it from the
|
|
94
|
+
* registry and mark it busy, so a racing `continue()`/`release()` for the same
|
|
95
|
+
* id can't observe the live entry and drive `strategy.run` over the same
|
|
96
|
+
* childLog/childCtx concurrently. Returns `undefined` if the id is unknown or
|
|
97
|
+
* already in flight.
|
|
98
|
+
*/
|
|
99
|
+
export function claimRetainedChild(childSessionId: SessionId): RetainedChildSession | undefined {
|
|
100
|
+
const id = String(childSessionId);
|
|
101
|
+
if (busy.has(id)) return undefined;
|
|
102
|
+
const entry = retained.get(id);
|
|
103
|
+
if (!entry) return undefined;
|
|
104
|
+
retained.delete(id);
|
|
105
|
+
busy.add(id);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Release a claim taken by {@link claimRetainedChild} (after the turn settles). */
|
|
110
|
+
export function unclaimRetainedChild(childSessionId: SessionId): void {
|
|
111
|
+
busy.delete(String(childSessionId));
|
|
112
|
+
}
|
|
113
|
+
|
|
43
114
|
export function releaseRetainedChild(childSessionId: SessionId): void {
|
|
44
|
-
|
|
115
|
+
const id = String(childSessionId);
|
|
116
|
+
retained.delete(id);
|
|
117
|
+
busy.delete(id);
|
|
45
118
|
}
|
|
46
119
|
|
|
47
120
|
/**
|
|
@@ -53,6 +126,7 @@ export function releaseRetainedChild(childSessionId: SessionId): void {
|
|
|
53
126
|
export function clearRetainedChildren(parentSessionId?: SessionId): void {
|
|
54
127
|
if (parentSessionId === undefined) {
|
|
55
128
|
retained.clear();
|
|
129
|
+
busy.clear();
|
|
56
130
|
return;
|
|
57
131
|
}
|
|
58
132
|
const target = String(parentSessionId);
|
|
@@ -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,158 @@ 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('a rejecting child never aborts its siblings; per-child failure carries the spec label', async () => {
|
|
253
|
+
const session = buildSession(['parent-model']);
|
|
254
|
+
const rt = buildRuntime(session, 'parent-model');
|
|
255
|
+
const spawner = createSubagentSpawner(rt);
|
|
256
|
+
|
|
257
|
+
// Make the THIRD child's runChildTurn reject outright (a pre-`try` setup throw
|
|
258
|
+
// inside buildChildContext) so the promise actually rejects rather than being
|
|
259
|
+
// self-degraded. With Promise.all the first rejection short-circuits the batch
|
|
260
|
+
// and orphans the two still-running siblings; Promise.allSettled must let every
|
|
261
|
+
// sibling finish and turn the rejection into one error-bearing result.
|
|
262
|
+
const realAppContext = session.appContext.bind(session);
|
|
263
|
+
let appCtxCalls = 0;
|
|
264
|
+
vi.spyOn(session, 'appContext').mockImplementation(() => {
|
|
265
|
+
appCtxCalls += 1;
|
|
266
|
+
if (appCtxCalls === 3) throw new Error('boom: setup exploded');
|
|
267
|
+
return realAppContext();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const results = await spawner.spawnAll([
|
|
271
|
+
{ prompt: 'a', mode: 'echo-model', label: 'one' },
|
|
272
|
+
{ prompt: 'b', mode: 'echo-model', label: 'two' },
|
|
273
|
+
{ prompt: 'c', mode: 'echo-model', label: 'three' },
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
// The batch resolves (never rejects) with one result per spec, in input order.
|
|
277
|
+
expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
|
|
278
|
+
// The two non-throwing siblings completed normally...
|
|
279
|
+
expect(results[0]!.stopReason).toBe('end_turn');
|
|
280
|
+
expect(results[0]!.text).toBe('parent-model');
|
|
281
|
+
expect(results[1]!.stopReason).toBe('end_turn');
|
|
282
|
+
expect(results[1]!.text).toBe('parent-model');
|
|
283
|
+
// ...and only the doomed child degraded to a per-child failure that keeps its label.
|
|
284
|
+
expect(results[2]!.stopReason).toBe('error');
|
|
285
|
+
expect(results[2]!.label).toBe('three');
|
|
286
|
+
expect(results[2]!.error?.message).toContain('boom: setup exploded');
|
|
287
|
+
vi.restoreAllMocks();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns one result per spec in input order on the all-success path', async () => {
|
|
291
|
+
const session = buildSession(['parent-model']);
|
|
292
|
+
const rt = buildRuntime(session, 'parent-model');
|
|
293
|
+
const spawner = createSubagentSpawner(rt);
|
|
294
|
+
|
|
295
|
+
const results = await spawner.spawnAll([
|
|
296
|
+
{ prompt: 'a', mode: 'echo-model', label: 'one' },
|
|
297
|
+
{ prompt: 'b', mode: 'echo-model', label: 'two' },
|
|
298
|
+
{ prompt: 'c', mode: 'echo-model', label: 'three' },
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
expect(results.map((r) => r.label)).toEqual(['one', 'two', 'three']);
|
|
302
|
+
expect(results.every((r) => r.stopReason === 'end_turn')).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('continueChildTurn re-entrancy', () => {
|
|
307
|
+
it('rejects a concurrent continue() for the same retained child (claim-then-run)', async () => {
|
|
308
|
+
let openGate!: () => void;
|
|
309
|
+
const gate = new Promise<void>((resolve) => {
|
|
310
|
+
openGate = resolve;
|
|
311
|
+
});
|
|
312
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
313
|
+
session.pluginHost.registerStatic(
|
|
314
|
+
definePlugin({
|
|
315
|
+
name: 'gated-test',
|
|
316
|
+
version: '0.0.0',
|
|
317
|
+
providers: [makeProvider(['m'])],
|
|
318
|
+
modes: [makeGatedMode(gate)],
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
session.providers.setActive('listed');
|
|
322
|
+
session.modes.setActive('gated');
|
|
323
|
+
|
|
324
|
+
const rt = buildRuntime(session, 'm');
|
|
325
|
+
const first = await runChildTurn({
|
|
326
|
+
rt,
|
|
327
|
+
spec: { prompt: 'go', mode: 'gated', retainSession: true },
|
|
328
|
+
retainSession: true,
|
|
329
|
+
});
|
|
330
|
+
const childId = first.childSessionId;
|
|
331
|
+
|
|
332
|
+
// Start the first continue (blocks inside the gated mode), then race a
|
|
333
|
+
// second continue for the same id before the first settles.
|
|
334
|
+
const p1 = continueChildTurn({ childSessionId: childId, prompt: 'again' });
|
|
335
|
+
// The entry is claimed synchronously, so the second continue must fail fast.
|
|
336
|
+
await expect(
|
|
337
|
+
continueChildTurn({ childSessionId: childId, prompt: 'race' }),
|
|
338
|
+
).rejects.toThrow(/no retained subagent session/);
|
|
339
|
+
|
|
340
|
+
openGate();
|
|
341
|
+
const r1 = await p1;
|
|
342
|
+
expect(r1.text).toBe('done');
|
|
343
|
+
|
|
344
|
+
clearRetainedChildren(session.id);
|
|
345
|
+
});
|
|
346
|
+
});
|