@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
|
@@ -126,6 +126,65 @@ describe('synthesizeSkill', () => {
|
|
|
126
126
|
expect(b.path).not.toBe(a.path);
|
|
127
127
|
expect(b.path).toMatch(/-2\.md$/);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it('does not overwrite a same-slug file already on disk (atomic wx create)', async () => {
|
|
131
|
+
const provider = new InlineProvider([draftReply()]);
|
|
132
|
+
const session = newSessionWithProvider(provider);
|
|
133
|
+
const skillsDir = path.join(tmp, 'skills');
|
|
134
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
135
|
+
// Pre-existing skill file with the slug that this synthesis will produce.
|
|
136
|
+
const squatted = path.join(skillsDir, 'refactor-component.md');
|
|
137
|
+
await fs.writeFile(squatted, 'PRE-EXISTING — MUST NOT BE TRUNCATED');
|
|
138
|
+
|
|
139
|
+
const result = await synthesizeSkill(session, 'split component up', 'user', {
|
|
140
|
+
userDir: skillsDir,
|
|
141
|
+
auditPath: path.join(tmp, 'audit.jsonl'),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The pre-existing file is untouched and the new skill landed on a bumped name.
|
|
145
|
+
expect(await fs.readFile(squatted, 'utf8')).toBe('PRE-EXISTING — MUST NOT BE TRUNCATED');
|
|
146
|
+
expect(result.path).toMatch(/-2\.md$/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('still returns the created skill when the audit append fails (audit is best-effort)', async () => {
|
|
150
|
+
const provider = new InlineProvider([draftReply()]);
|
|
151
|
+
const session = newSessionWithProvider(provider);
|
|
152
|
+
// Make the audit path unwritable by planting a FILE where its parent dir
|
|
153
|
+
// must be — appendAudit's mkdir(dirname) then throws ENOTDIR/EEXIST.
|
|
154
|
+
const blocker = path.join(tmp, 'blocker');
|
|
155
|
+
await fs.writeFile(blocker, 'i am a file, not a directory');
|
|
156
|
+
const auditPath = path.join(blocker, 'created.jsonl');
|
|
157
|
+
|
|
158
|
+
const result = await synthesizeSkill(session, 'split component up', 'user', {
|
|
159
|
+
userDir: path.join(tmp, 'skills'),
|
|
160
|
+
auditPath,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The skill is the product — it was written, registered, and returned despite
|
|
164
|
+
// the telemetry write failing.
|
|
165
|
+
expect(result.skill.frontmatter.name).toBe('refactor-component');
|
|
166
|
+
expect(await fs.readFile(result.path, 'utf8')).toContain('refactor-component');
|
|
167
|
+
expect(session.skills.byName('refactor-component')).toBeDefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('caps the audit JSONL at MAX_AUDIT_LINES', async () => {
|
|
171
|
+
const auditPath = path.join(tmp, 'audit.jsonl');
|
|
172
|
+
// Seed the audit with way more than the cap.
|
|
173
|
+
const seed = Array.from({ length: 2100 }, (_, i) => JSON.stringify({ n: i })).join('\n') + '\n';
|
|
174
|
+
await fs.writeFile(auditPath, seed);
|
|
175
|
+
|
|
176
|
+
const provider = new InlineProvider([draftReply()]);
|
|
177
|
+
const session = newSessionWithProvider(provider);
|
|
178
|
+
await synthesizeSkill(session, 'split component up', 'user', {
|
|
179
|
+
userDir: path.join(tmp, 'skills'),
|
|
180
|
+
auditPath,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const lines = (await fs.readFile(auditPath, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
184
|
+
expect(lines.length).toBeLessThanOrEqual(2000);
|
|
185
|
+
// The most-recent line is the one we just appended (the skill slug).
|
|
186
|
+
expect(lines[lines.length - 1]).toContain('refactor-component');
|
|
187
|
+
});
|
|
129
188
|
});
|
|
130
189
|
|
|
131
190
|
describe('buildSynthesizeSkillPlugin', () => {
|
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);
|