groove-dev 0.27.116 → 0.27.117
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/moe-training/client/domain-tagger.js +20 -0
- package/moe-training/client/trajectory-capture.js +36 -7
- package/moe-training/test/client/trajectory-capture.test.js +182 -1
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +35 -9
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +18 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/moe-training/client/domain-tagger.js +20 -0
- package/node_modules/moe-training/client/trajectory-capture.js +36 -7
- package/node_modules/moe-training/test/client/trajectory-capture.test.js +182 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/process.js +35 -9
- package/packages/daemon/src/tunnel-manager.js +18 -1
- package/packages/gui/package.json +1 -1
|
@@ -169,6 +169,26 @@ const DOMAIN_TAXONOMY = {
|
|
|
169
169
|
keywords: ['scientific', 'simulation', 'matlab', 'scipy', 'julia', 'fortran', 'numerical', 'differential equation', 'finite element', 'linear algebra'],
|
|
170
170
|
description: 'Scientific computing, numerical methods, MATLAB/SciPy/Julia, simulations, optimization, statistics',
|
|
171
171
|
},
|
|
172
|
+
planning_strategy: {
|
|
173
|
+
keywords: ['plan', 'strategy', 'architect', 'design doc', 'breakdown', 'scope', 'roadmap', 'milestone', 'prioritize', 'tradeoff', 'approach', 'recommend', 'team', 'coordinate', 'delegate', 'assign'],
|
|
174
|
+
description: 'Project planning, task breakdown, architecture decisions, team coordination, strategy, roadmaps, scoping, prioritization',
|
|
175
|
+
},
|
|
176
|
+
conversational_reasoning: {
|
|
177
|
+
keywords: ['explain', 'why', 'how does', 'what is', 'clarify', 'understand', 'reason', 'think through', 'analyze', 'compare', 'evaluate', 'brainstorm', 'discuss', 'opinion', 'advice'],
|
|
178
|
+
description: 'Conversational reasoning, explanation, analysis, brainstorming, Q&A, decision-making, advice, evaluation',
|
|
179
|
+
},
|
|
180
|
+
documentation_writing: {
|
|
181
|
+
keywords: ['readme', 'documentation', 'docs', 'markdown', 'api docs', 'changelog', 'tutorial', 'guide', 'specification', 'wiki', 'jsdoc', 'docstring', 'technical writing'],
|
|
182
|
+
description: 'Documentation writing, READMEs, API docs, changelogs, tutorials, guides, technical writing, specifications',
|
|
183
|
+
},
|
|
184
|
+
product_design: {
|
|
185
|
+
keywords: ['product', 'feature', 'user story', 'requirements', 'ux', 'wireframe', 'prototype', 'feedback', 'iteration', 'mvp', 'spec', 'acceptance criteria', 'stakeholder'],
|
|
186
|
+
description: 'Product design, feature planning, user stories, requirements gathering, UX, prototyping, MVPs, stakeholder communication',
|
|
187
|
+
},
|
|
188
|
+
devops_general: {
|
|
189
|
+
keywords: ['deploy', 'deployment', 'release', 'rollback', 'staging', 'production', 'environment', 'migration', 'upgrade', 'maintenance', 'incident', 'postmortem', 'runbook'],
|
|
190
|
+
description: 'DevOps operations, deployments, releases, rollbacks, environment management, incident response, runbooks',
|
|
191
|
+
},
|
|
172
192
|
};
|
|
173
193
|
|
|
174
194
|
export class DomainTagger {
|
|
@@ -33,9 +33,11 @@ export class TrajectoryCapture {
|
|
|
33
33
|
this._transmissionQueue = null;
|
|
34
34
|
this._offlineRetryTimer = null;
|
|
35
35
|
this._contexts = new Map();
|
|
36
|
+
this._shutdown = false;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async init() {
|
|
40
|
+
if (this._shutdown) return;
|
|
39
41
|
if (!ConsentManager.isCaptureEnabled()) {
|
|
40
42
|
this._enabled = false;
|
|
41
43
|
return;
|
|
@@ -47,12 +49,16 @@ export class TrajectoryCapture {
|
|
|
47
49
|
this._transmissionQueue.start();
|
|
48
50
|
this._domainTagger = new DomainTagger();
|
|
49
51
|
await this._domainTagger.init();
|
|
52
|
+
if (this._shutdown) return;
|
|
50
53
|
this._offlineRetryTimer = setInterval(() => {
|
|
51
54
|
this._retryOfflineQueue();
|
|
52
55
|
}, OFFLINE_RETRY_INTERVAL_MS);
|
|
56
|
+
if (typeof this._offlineRetryTimer.unref === 'function') {
|
|
57
|
+
this._offlineRetryTimer.unref();
|
|
58
|
+
}
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
async onAgentSpawn(agentId, provider, model, role, teamSize) {
|
|
61
|
+
async onAgentSpawn(agentId, provider, model, role, teamSize, prompt) {
|
|
56
62
|
if (!this._enabled) return;
|
|
57
63
|
|
|
58
64
|
const parser = getParser(provider);
|
|
@@ -98,9 +104,20 @@ export class TrajectoryCapture {
|
|
|
98
104
|
ctx.chunkTimer = setInterval(() => {
|
|
99
105
|
this._flushContext(agentId);
|
|
100
106
|
}, CHUNK_TIMEOUT_MS);
|
|
107
|
+
if (typeof ctx.chunkTimer.unref === 'function') {
|
|
108
|
+
ctx.chunkTimer.unref();
|
|
109
|
+
}
|
|
101
110
|
|
|
102
111
|
this._contexts.set(agentId, ctx);
|
|
103
112
|
|
|
113
|
+
if (prompt && typeof prompt === 'string' && prompt.trim()) {
|
|
114
|
+
this._processStep(agentId, ctx, {
|
|
115
|
+
type: 'instruction',
|
|
116
|
+
content: prompt.slice(0, USER_MESSAGE_MAX_CHARS),
|
|
117
|
+
source: 'user',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
await this._attestation.openSession(sessionId, metadata);
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -205,6 +222,7 @@ export class TrajectoryCapture {
|
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
async shutdown() {
|
|
225
|
+
this._shutdown = true;
|
|
208
226
|
if (this._offlineRetryTimer) clearInterval(this._offlineRetryTimer);
|
|
209
227
|
for (const agentId of this._contexts.keys()) {
|
|
210
228
|
await this._closeAgent(agentId, 'SHUTDOWN');
|
|
@@ -368,6 +386,23 @@ export class TrajectoryCapture {
|
|
|
368
386
|
}
|
|
369
387
|
|
|
370
388
|
_computeTrainingEligibility(ctx, durationSeconds) {
|
|
389
|
+
const role = ctx.metadata.agent_role || '';
|
|
390
|
+
const isConversational = role === 'planner' || role === 'chat' || role === 'advisor';
|
|
391
|
+
|
|
392
|
+
if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
|
|
393
|
+
return { eligible: false, exclusionReason: 'insufficient_tokens' };
|
|
394
|
+
}
|
|
395
|
+
if (durationSeconds < TRAINING_MIN_DURATION) {
|
|
396
|
+
return { eligible: false, exclusionReason: 'too_short' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (isConversational) {
|
|
400
|
+
if (ctx.stepCount < 2) {
|
|
401
|
+
return { eligible: false, exclusionReason: 'too_few_steps' };
|
|
402
|
+
}
|
|
403
|
+
return { eligible: true, exclusionReason: null };
|
|
404
|
+
}
|
|
405
|
+
|
|
371
406
|
if (ctx.stepCount < TRAINING_MIN_STEPS) {
|
|
372
407
|
return { eligible: false, exclusionReason: 'too_few_steps' };
|
|
373
408
|
}
|
|
@@ -379,12 +414,6 @@ export class TrajectoryCapture {
|
|
|
379
414
|
if (!hasObservation) {
|
|
380
415
|
return { eligible: false, exclusionReason: 'no_observations' };
|
|
381
416
|
}
|
|
382
|
-
if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
|
|
383
|
-
return { eligible: false, exclusionReason: 'insufficient_tokens' };
|
|
384
|
-
}
|
|
385
|
-
if (durationSeconds < TRAINING_MIN_DURATION) {
|
|
386
|
-
return { eligible: false, exclusionReason: 'too_short' };
|
|
387
|
-
}
|
|
388
417
|
return { eligible: true, exclusionReason: null };
|
|
389
418
|
}
|
|
390
419
|
|
|
@@ -208,7 +208,7 @@ describe('TrajectoryCapture — training eligibility', () => {
|
|
|
208
208
|
assert.equal(result.exclusionReason, null);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
it('exclusion reasons follow priority order', () => {
|
|
211
|
+
it('exclusion reasons follow priority order: tokens before duration before steps', () => {
|
|
212
212
|
const tc = makeTc();
|
|
213
213
|
const ctx = makeCtx({
|
|
214
214
|
stepCount: 3,
|
|
@@ -220,8 +220,189 @@ describe('TrajectoryCapture — training eligibility', () => {
|
|
|
220
220
|
],
|
|
221
221
|
});
|
|
222
222
|
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
223
|
+
assert.equal(result.exclusionReason, 'insufficient_tokens');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('duration checked before steps when tokens pass', () => {
|
|
227
|
+
const tc = makeTc();
|
|
228
|
+
const ctx = makeCtx({
|
|
229
|
+
stepCount: 3,
|
|
230
|
+
totalTokens: 5000,
|
|
231
|
+
allSteps: [
|
|
232
|
+
{ step: 1, type: 'thought', content: 'thinking' },
|
|
233
|
+
{ step: 2, type: 'thought', content: 'more' },
|
|
234
|
+
{ step: 3, type: 'thought', content: 'done' },
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
238
|
+
assert.equal(result.exclusionReason, 'too_short');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('steps checked after tokens and duration pass', () => {
|
|
242
|
+
const tc = makeTc();
|
|
243
|
+
const ctx = makeCtx({
|
|
244
|
+
stepCount: 3,
|
|
245
|
+
totalTokens: 5000,
|
|
246
|
+
allSteps: [
|
|
247
|
+
{ step: 1, type: 'thought', content: 'thinking' },
|
|
248
|
+
{ step: 2, type: 'thought', content: 'more' },
|
|
249
|
+
{ step: 3, type: 'thought', content: 'done' },
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
253
|
+
assert.equal(result.exclusionReason, 'too_few_steps');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('TrajectoryCapture — planner/conversational eligibility', () => {
|
|
258
|
+
function makeConversationalCtx(role, overrides = {}) {
|
|
259
|
+
const ctx = makeCtx(overrides);
|
|
260
|
+
ctx.metadata.agent_role = role;
|
|
261
|
+
return ctx;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('planner eligible with only thoughts (no actions/observations)', () => {
|
|
265
|
+
const tc = makeTc();
|
|
266
|
+
const ctx = makeConversationalCtx('planner', {
|
|
267
|
+
stepCount: 10,
|
|
268
|
+
totalTokens: 2000,
|
|
269
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'planning' })),
|
|
270
|
+
});
|
|
271
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
272
|
+
assert.equal(result.eligible, true);
|
|
273
|
+
assert.equal(result.exclusionReason, null);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('chat role eligible with only thoughts', () => {
|
|
277
|
+
const tc = makeTc();
|
|
278
|
+
const ctx = makeConversationalCtx('chat', {
|
|
279
|
+
stepCount: 5,
|
|
280
|
+
totalTokens: 1000,
|
|
281
|
+
allSteps: [
|
|
282
|
+
{ step: 1, type: 'instruction', content: 'explain React hooks' },
|
|
283
|
+
{ step: 2, type: 'thought', content: 'explaining' },
|
|
284
|
+
{ step: 3, type: 'thought', content: 'more detail' },
|
|
285
|
+
{ step: 4, type: 'thought', content: 'examples' },
|
|
286
|
+
{ step: 5, type: 'resolution', content: 'done' },
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const result = tc._computeTrainingEligibility(ctx, 30);
|
|
290
|
+
assert.equal(result.eligible, true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('advisor role eligible with only thoughts', () => {
|
|
294
|
+
const tc = makeTc();
|
|
295
|
+
const ctx = makeConversationalCtx('advisor', {
|
|
296
|
+
stepCount: 3,
|
|
297
|
+
totalTokens: 800,
|
|
298
|
+
allSteps: [
|
|
299
|
+
{ step: 1, type: 'instruction', content: 'review approach' },
|
|
300
|
+
{ step: 2, type: 'thought', content: 'analysis' },
|
|
301
|
+
{ step: 3, type: 'resolution', content: 'recommendation' },
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
const result = tc._computeTrainingEligibility(ctx, 20);
|
|
305
|
+
assert.equal(result.eligible, true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('planner still requires minimum tokens', () => {
|
|
309
|
+
const tc = makeTc();
|
|
310
|
+
const ctx = makeConversationalCtx('planner', {
|
|
311
|
+
stepCount: 10,
|
|
312
|
+
totalTokens: 100,
|
|
313
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
|
|
314
|
+
});
|
|
315
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
316
|
+
assert.equal(result.eligible, false);
|
|
317
|
+
assert.equal(result.exclusionReason, 'insufficient_tokens');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('planner still requires minimum duration', () => {
|
|
321
|
+
const tc = makeTc();
|
|
322
|
+
const ctx = makeConversationalCtx('planner', {
|
|
323
|
+
stepCount: 10,
|
|
324
|
+
totalTokens: 2000,
|
|
325
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
|
|
326
|
+
});
|
|
327
|
+
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
328
|
+
assert.equal(result.eligible, false);
|
|
329
|
+
assert.equal(result.exclusionReason, 'too_short');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('planner requires at least 2 steps', () => {
|
|
333
|
+
const tc = makeTc();
|
|
334
|
+
const ctx = makeConversationalCtx('planner', {
|
|
335
|
+
stepCount: 1,
|
|
336
|
+
totalTokens: 2000,
|
|
337
|
+
allSteps: [{ step: 1, type: 'thought', content: 'plan' }],
|
|
338
|
+
});
|
|
339
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
340
|
+
assert.equal(result.eligible, false);
|
|
223
341
|
assert.equal(result.exclusionReason, 'too_few_steps');
|
|
224
342
|
});
|
|
343
|
+
|
|
344
|
+
it('coding role (fullstack) still requires actions and observations', () => {
|
|
345
|
+
const tc = makeTc();
|
|
346
|
+
const ctx = makeConversationalCtx('fullstack', {
|
|
347
|
+
stepCount: 10,
|
|
348
|
+
totalTokens: 2000,
|
|
349
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'thinking' })),
|
|
350
|
+
});
|
|
351
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
352
|
+
assert.equal(result.eligible, false);
|
|
353
|
+
assert.equal(result.exclusionReason, 'no_actions');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('TrajectoryCapture — initial prompt capture', () => {
|
|
358
|
+
function makeSpawnTc() {
|
|
359
|
+
const tc = makeTc();
|
|
360
|
+
tc._enabled = true;
|
|
361
|
+
tc._scrubber = { scrub: (s) => s };
|
|
362
|
+
tc._attestation = { openSession: async () => {}, signEnvelope: (sid, e) => e };
|
|
363
|
+
tc._transmissionQueue = { enqueue: () => {} };
|
|
364
|
+
tc._domainTagger = null;
|
|
365
|
+
return tc;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it('onAgentSpawn records prompt as instruction step', async () => {
|
|
369
|
+
const tc = makeSpawnTc();
|
|
370
|
+
await tc.onAgentSpawn('agent-p1', 'claude-code', 'opus', 'planner', 1, 'Build a React app');
|
|
371
|
+
|
|
372
|
+
const ctx = tc._contexts.get('agent-p1');
|
|
373
|
+
assert.ok(ctx);
|
|
374
|
+
assert.equal(ctx.stepCount, 1);
|
|
375
|
+
assert.equal(ctx.allSteps[0].type, 'instruction');
|
|
376
|
+
assert.ok(ctx.allSteps[0].content.includes('Build a React app'));
|
|
377
|
+
assert.equal(ctx.allSteps[0].source, 'user');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('onAgentSpawn with no prompt creates no instruction step', async () => {
|
|
381
|
+
const tc = makeSpawnTc();
|
|
382
|
+
await tc.onAgentSpawn('agent-p2', 'claude-code', 'opus', 'fullstack', 1);
|
|
383
|
+
|
|
384
|
+
const ctx = tc._contexts.get('agent-p2');
|
|
385
|
+
assert.ok(ctx);
|
|
386
|
+
assert.equal(ctx.stepCount, 0);
|
|
387
|
+
assert.equal(ctx.allSteps.length, 0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('onAgentSpawn truncates long prompts', async () => {
|
|
391
|
+
const tc = makeSpawnTc();
|
|
392
|
+
const longPrompt = 'x'.repeat(50000);
|
|
393
|
+
await tc.onAgentSpawn('agent-p3', 'claude-code', 'opus', 'planner', 1, longPrompt);
|
|
394
|
+
|
|
395
|
+
const ctx = tc._contexts.get('agent-p3');
|
|
396
|
+
assert.ok(ctx.allSteps[0].content.length <= 10001);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('onAgentSpawn ignores empty/whitespace prompts', async () => {
|
|
400
|
+
const tc = makeSpawnTc();
|
|
401
|
+
await tc.onAgentSpawn('agent-p4', 'claude-code', 'opus', 'planner', 1, ' ');
|
|
402
|
+
|
|
403
|
+
const ctx = tc._contexts.get('agent-p4');
|
|
404
|
+
assert.equal(ctx.stepCount, 0);
|
|
405
|
+
});
|
|
225
406
|
});
|
|
226
407
|
|
|
227
408
|
describe('TrajectoryCapture — user feedback emission', () => {
|
|
@@ -330,6 +330,7 @@ export class ProcessManager {
|
|
|
330
330
|
this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
|
|
331
331
|
this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
|
|
332
332
|
this._exitHandled = new Set();
|
|
333
|
+
this._resultReceived = new Set();
|
|
333
334
|
|
|
334
335
|
this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
|
|
335
336
|
if (this._stallWatchdog.unref) this._stallWatchdog.unref();
|
|
@@ -389,6 +390,7 @@ export class ProcessManager {
|
|
|
389
390
|
this._exitHandled.add(agentId);
|
|
390
391
|
setTimeout(() => this._exitHandled.delete(agentId), 30_000);
|
|
391
392
|
this._stalledAgents.delete(agentId);
|
|
393
|
+
this._resultReceived.delete(agentId);
|
|
392
394
|
const throttle = this._streamThrottle.get(agentId);
|
|
393
395
|
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
394
396
|
this._streamThrottle.delete(agentId);
|
|
@@ -425,11 +427,16 @@ export class ProcessManager {
|
|
|
425
427
|
|
|
426
428
|
if (this.daemon.locks) this.daemon.locks.release(agent.id);
|
|
427
429
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
430
|
+
const hadResult = this._resultReceived.has(agent.id);
|
|
431
|
+
this._resultReceived.delete(agent.id);
|
|
432
|
+
|
|
433
|
+
const finalStatus = hadResult
|
|
434
|
+
? 'completed'
|
|
435
|
+
: signal === 'SIGTERM' || signal === 'SIGKILL'
|
|
436
|
+
? 'killed'
|
|
437
|
+
: code === 0
|
|
438
|
+
? 'completed'
|
|
439
|
+
: 'crashed';
|
|
433
440
|
|
|
434
441
|
const crashError = finalStatus === 'crashed' ? stderrBuf.join('').trim().slice(-500) : null;
|
|
435
442
|
|
|
@@ -552,7 +559,10 @@ export class ProcessManager {
|
|
|
552
559
|
|
|
553
560
|
if (this.daemon.locks) this.daemon.locks.release(agent.id);
|
|
554
561
|
|
|
555
|
-
const
|
|
562
|
+
const hadResult = this._resultReceived.has(agent.id);
|
|
563
|
+
this._resultReceived.delete(agent.id);
|
|
564
|
+
|
|
565
|
+
const finalStatus = hadResult ? 'completed' : signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
|
|
556
566
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
557
567
|
|
|
558
568
|
if (this.daemon.trajectoryCapture) {
|
|
@@ -727,7 +737,7 @@ export class ProcessManager {
|
|
|
727
737
|
try {
|
|
728
738
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
729
739
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
730
|
-
agent.id, providerName, config.model || null, config.role, teamSize
|
|
740
|
+
agent.id, providerName, config.model || null, config.role, teamSize, config.prompt
|
|
731
741
|
).catch(() => {});
|
|
732
742
|
} catch (e) { /* fail silent */ }
|
|
733
743
|
}
|
|
@@ -970,6 +980,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
970
980
|
logStream.end();
|
|
971
981
|
this.handles.delete(agent.id);
|
|
972
982
|
this._stalledAgents.delete(agent.id);
|
|
983
|
+
this._resultReceived.delete(agent.id);
|
|
973
984
|
|
|
974
985
|
// Clean up stream throttle so pending timers don't fire for dead agents
|
|
975
986
|
const throttle = this._streamThrottle.get(agent.id);
|
|
@@ -1262,6 +1273,21 @@ For normal file edits within your scope, proceed without review.
|
|
|
1262
1273
|
if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
|
|
1263
1274
|
if (output.duration) updates.durationMs = output.duration;
|
|
1264
1275
|
if (output.turns) updates.turns = output.turns;
|
|
1276
|
+
|
|
1277
|
+
// Claude Code sometimes hangs after emitting the result event — the
|
|
1278
|
+
// process stays alive instead of exiting. Record that the result
|
|
1279
|
+
// arrived so exit handlers know this was a successful completion even
|
|
1280
|
+
// if we have to SIGTERM the process. After a 5s grace period, force-
|
|
1281
|
+
// kill any process that hasn't exited on its own.
|
|
1282
|
+
this._resultReceived.add(agentId);
|
|
1283
|
+
const handle = this.handles.get(agentId);
|
|
1284
|
+
if (handle?.proc && typeof handle.proc.kill === 'function') {
|
|
1285
|
+
setTimeout(() => {
|
|
1286
|
+
if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
|
|
1287
|
+
try { handle.proc.kill('SIGTERM'); } catch {}
|
|
1288
|
+
}
|
|
1289
|
+
}, 5_000);
|
|
1290
|
+
}
|
|
1265
1291
|
}
|
|
1266
1292
|
|
|
1267
1293
|
// Context window usage (0-1 scale) — drives rotation threshold
|
|
@@ -1845,7 +1871,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1845
1871
|
try {
|
|
1846
1872
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
1847
1873
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
1848
|
-
newAgent.id, config.provider, config.model || null, config.role, teamSize
|
|
1874
|
+
newAgent.id, config.provider, config.model || null, config.role, teamSize, config.prompt
|
|
1849
1875
|
).catch(() => {});
|
|
1850
1876
|
} catch (e) { /* fail silent */ }
|
|
1851
1877
|
}
|
|
@@ -1987,7 +2013,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1987
2013
|
try {
|
|
1988
2014
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
1989
2015
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
1990
|
-
newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize
|
|
2016
|
+
newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize, config.prompt
|
|
1991
2017
|
).catch(() => {});
|
|
1992
2018
|
} catch (e) { /* fail silent */ }
|
|
1993
2019
|
}
|
|
@@ -274,9 +274,11 @@ export class TunnelManager {
|
|
|
274
274
|
throw new Error(testResult.error || 'Host unreachable');
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
let preConnectHandled = false;
|
|
277
278
|
if (!testResult.daemonRunning && !testResult.grooveInstalled) {
|
|
278
279
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
|
|
279
280
|
await this.remoteInstall(id);
|
|
281
|
+
preConnectHandled = true;
|
|
280
282
|
} else if (!testResult.daemonRunning && testResult.grooveInstalled) {
|
|
281
283
|
const localVer = getLocalVersion();
|
|
282
284
|
if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
|
|
@@ -285,6 +287,7 @@ export class TunnelManager {
|
|
|
285
287
|
}
|
|
286
288
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
287
289
|
await this.autoStart(id);
|
|
290
|
+
preConnectHandled = true;
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
@@ -342,7 +345,9 @@ export class TunnelManager {
|
|
|
342
345
|
failCount: 0,
|
|
343
346
|
});
|
|
344
347
|
|
|
345
|
-
|
|
348
|
+
if (!preConnectHandled) {
|
|
349
|
+
await this._checkAndUpgradeRunning(id, config, localPort);
|
|
350
|
+
}
|
|
346
351
|
|
|
347
352
|
try {
|
|
348
353
|
const statusResp = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -473,6 +478,18 @@ export class TunnelManager {
|
|
|
473
478
|
|
|
474
479
|
this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
|
|
475
480
|
} catch (err) {
|
|
481
|
+
try {
|
|
482
|
+
const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
483
|
+
if (verify.ok) {
|
|
484
|
+
const verifyData = await verify.json();
|
|
485
|
+
if (verifyData.version === localVer) {
|
|
486
|
+
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
} catch { /* tunnel verification failed */ }
|
|
476
493
|
this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: err.message } });
|
|
477
494
|
}
|
|
478
495
|
}
|
|
@@ -169,6 +169,26 @@ const DOMAIN_TAXONOMY = {
|
|
|
169
169
|
keywords: ['scientific', 'simulation', 'matlab', 'scipy', 'julia', 'fortran', 'numerical', 'differential equation', 'finite element', 'linear algebra'],
|
|
170
170
|
description: 'Scientific computing, numerical methods, MATLAB/SciPy/Julia, simulations, optimization, statistics',
|
|
171
171
|
},
|
|
172
|
+
planning_strategy: {
|
|
173
|
+
keywords: ['plan', 'strategy', 'architect', 'design doc', 'breakdown', 'scope', 'roadmap', 'milestone', 'prioritize', 'tradeoff', 'approach', 'recommend', 'team', 'coordinate', 'delegate', 'assign'],
|
|
174
|
+
description: 'Project planning, task breakdown, architecture decisions, team coordination, strategy, roadmaps, scoping, prioritization',
|
|
175
|
+
},
|
|
176
|
+
conversational_reasoning: {
|
|
177
|
+
keywords: ['explain', 'why', 'how does', 'what is', 'clarify', 'understand', 'reason', 'think through', 'analyze', 'compare', 'evaluate', 'brainstorm', 'discuss', 'opinion', 'advice'],
|
|
178
|
+
description: 'Conversational reasoning, explanation, analysis, brainstorming, Q&A, decision-making, advice, evaluation',
|
|
179
|
+
},
|
|
180
|
+
documentation_writing: {
|
|
181
|
+
keywords: ['readme', 'documentation', 'docs', 'markdown', 'api docs', 'changelog', 'tutorial', 'guide', 'specification', 'wiki', 'jsdoc', 'docstring', 'technical writing'],
|
|
182
|
+
description: 'Documentation writing, READMEs, API docs, changelogs, tutorials, guides, technical writing, specifications',
|
|
183
|
+
},
|
|
184
|
+
product_design: {
|
|
185
|
+
keywords: ['product', 'feature', 'user story', 'requirements', 'ux', 'wireframe', 'prototype', 'feedback', 'iteration', 'mvp', 'spec', 'acceptance criteria', 'stakeholder'],
|
|
186
|
+
description: 'Product design, feature planning, user stories, requirements gathering, UX, prototyping, MVPs, stakeholder communication',
|
|
187
|
+
},
|
|
188
|
+
devops_general: {
|
|
189
|
+
keywords: ['deploy', 'deployment', 'release', 'rollback', 'staging', 'production', 'environment', 'migration', 'upgrade', 'maintenance', 'incident', 'postmortem', 'runbook'],
|
|
190
|
+
description: 'DevOps operations, deployments, releases, rollbacks, environment management, incident response, runbooks',
|
|
191
|
+
},
|
|
172
192
|
};
|
|
173
193
|
|
|
174
194
|
export class DomainTagger {
|
|
@@ -33,9 +33,11 @@ export class TrajectoryCapture {
|
|
|
33
33
|
this._transmissionQueue = null;
|
|
34
34
|
this._offlineRetryTimer = null;
|
|
35
35
|
this._contexts = new Map();
|
|
36
|
+
this._shutdown = false;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async init() {
|
|
40
|
+
if (this._shutdown) return;
|
|
39
41
|
if (!ConsentManager.isCaptureEnabled()) {
|
|
40
42
|
this._enabled = false;
|
|
41
43
|
return;
|
|
@@ -47,12 +49,16 @@ export class TrajectoryCapture {
|
|
|
47
49
|
this._transmissionQueue.start();
|
|
48
50
|
this._domainTagger = new DomainTagger();
|
|
49
51
|
await this._domainTagger.init();
|
|
52
|
+
if (this._shutdown) return;
|
|
50
53
|
this._offlineRetryTimer = setInterval(() => {
|
|
51
54
|
this._retryOfflineQueue();
|
|
52
55
|
}, OFFLINE_RETRY_INTERVAL_MS);
|
|
56
|
+
if (typeof this._offlineRetryTimer.unref === 'function') {
|
|
57
|
+
this._offlineRetryTimer.unref();
|
|
58
|
+
}
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
async onAgentSpawn(agentId, provider, model, role, teamSize) {
|
|
61
|
+
async onAgentSpawn(agentId, provider, model, role, teamSize, prompt) {
|
|
56
62
|
if (!this._enabled) return;
|
|
57
63
|
|
|
58
64
|
const parser = getParser(provider);
|
|
@@ -98,9 +104,20 @@ export class TrajectoryCapture {
|
|
|
98
104
|
ctx.chunkTimer = setInterval(() => {
|
|
99
105
|
this._flushContext(agentId);
|
|
100
106
|
}, CHUNK_TIMEOUT_MS);
|
|
107
|
+
if (typeof ctx.chunkTimer.unref === 'function') {
|
|
108
|
+
ctx.chunkTimer.unref();
|
|
109
|
+
}
|
|
101
110
|
|
|
102
111
|
this._contexts.set(agentId, ctx);
|
|
103
112
|
|
|
113
|
+
if (prompt && typeof prompt === 'string' && prompt.trim()) {
|
|
114
|
+
this._processStep(agentId, ctx, {
|
|
115
|
+
type: 'instruction',
|
|
116
|
+
content: prompt.slice(0, USER_MESSAGE_MAX_CHARS),
|
|
117
|
+
source: 'user',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
await this._attestation.openSession(sessionId, metadata);
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -205,6 +222,7 @@ export class TrajectoryCapture {
|
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
async shutdown() {
|
|
225
|
+
this._shutdown = true;
|
|
208
226
|
if (this._offlineRetryTimer) clearInterval(this._offlineRetryTimer);
|
|
209
227
|
for (const agentId of this._contexts.keys()) {
|
|
210
228
|
await this._closeAgent(agentId, 'SHUTDOWN');
|
|
@@ -368,6 +386,23 @@ export class TrajectoryCapture {
|
|
|
368
386
|
}
|
|
369
387
|
|
|
370
388
|
_computeTrainingEligibility(ctx, durationSeconds) {
|
|
389
|
+
const role = ctx.metadata.agent_role || '';
|
|
390
|
+
const isConversational = role === 'planner' || role === 'chat' || role === 'advisor';
|
|
391
|
+
|
|
392
|
+
if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
|
|
393
|
+
return { eligible: false, exclusionReason: 'insufficient_tokens' };
|
|
394
|
+
}
|
|
395
|
+
if (durationSeconds < TRAINING_MIN_DURATION) {
|
|
396
|
+
return { eligible: false, exclusionReason: 'too_short' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (isConversational) {
|
|
400
|
+
if (ctx.stepCount < 2) {
|
|
401
|
+
return { eligible: false, exclusionReason: 'too_few_steps' };
|
|
402
|
+
}
|
|
403
|
+
return { eligible: true, exclusionReason: null };
|
|
404
|
+
}
|
|
405
|
+
|
|
371
406
|
if (ctx.stepCount < TRAINING_MIN_STEPS) {
|
|
372
407
|
return { eligible: false, exclusionReason: 'too_few_steps' };
|
|
373
408
|
}
|
|
@@ -379,12 +414,6 @@ export class TrajectoryCapture {
|
|
|
379
414
|
if (!hasObservation) {
|
|
380
415
|
return { eligible: false, exclusionReason: 'no_observations' };
|
|
381
416
|
}
|
|
382
|
-
if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
|
|
383
|
-
return { eligible: false, exclusionReason: 'insufficient_tokens' };
|
|
384
|
-
}
|
|
385
|
-
if (durationSeconds < TRAINING_MIN_DURATION) {
|
|
386
|
-
return { eligible: false, exclusionReason: 'too_short' };
|
|
387
|
-
}
|
|
388
417
|
return { eligible: true, exclusionReason: null };
|
|
389
418
|
}
|
|
390
419
|
|
|
@@ -208,7 +208,7 @@ describe('TrajectoryCapture — training eligibility', () => {
|
|
|
208
208
|
assert.equal(result.exclusionReason, null);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
it('exclusion reasons follow priority order', () => {
|
|
211
|
+
it('exclusion reasons follow priority order: tokens before duration before steps', () => {
|
|
212
212
|
const tc = makeTc();
|
|
213
213
|
const ctx = makeCtx({
|
|
214
214
|
stepCount: 3,
|
|
@@ -220,8 +220,189 @@ describe('TrajectoryCapture — training eligibility', () => {
|
|
|
220
220
|
],
|
|
221
221
|
});
|
|
222
222
|
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
223
|
+
assert.equal(result.exclusionReason, 'insufficient_tokens');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('duration checked before steps when tokens pass', () => {
|
|
227
|
+
const tc = makeTc();
|
|
228
|
+
const ctx = makeCtx({
|
|
229
|
+
stepCount: 3,
|
|
230
|
+
totalTokens: 5000,
|
|
231
|
+
allSteps: [
|
|
232
|
+
{ step: 1, type: 'thought', content: 'thinking' },
|
|
233
|
+
{ step: 2, type: 'thought', content: 'more' },
|
|
234
|
+
{ step: 3, type: 'thought', content: 'done' },
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
238
|
+
assert.equal(result.exclusionReason, 'too_short');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('steps checked after tokens and duration pass', () => {
|
|
242
|
+
const tc = makeTc();
|
|
243
|
+
const ctx = makeCtx({
|
|
244
|
+
stepCount: 3,
|
|
245
|
+
totalTokens: 5000,
|
|
246
|
+
allSteps: [
|
|
247
|
+
{ step: 1, type: 'thought', content: 'thinking' },
|
|
248
|
+
{ step: 2, type: 'thought', content: 'more' },
|
|
249
|
+
{ step: 3, type: 'thought', content: 'done' },
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
253
|
+
assert.equal(result.exclusionReason, 'too_few_steps');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('TrajectoryCapture — planner/conversational eligibility', () => {
|
|
258
|
+
function makeConversationalCtx(role, overrides = {}) {
|
|
259
|
+
const ctx = makeCtx(overrides);
|
|
260
|
+
ctx.metadata.agent_role = role;
|
|
261
|
+
return ctx;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('planner eligible with only thoughts (no actions/observations)', () => {
|
|
265
|
+
const tc = makeTc();
|
|
266
|
+
const ctx = makeConversationalCtx('planner', {
|
|
267
|
+
stepCount: 10,
|
|
268
|
+
totalTokens: 2000,
|
|
269
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'planning' })),
|
|
270
|
+
});
|
|
271
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
272
|
+
assert.equal(result.eligible, true);
|
|
273
|
+
assert.equal(result.exclusionReason, null);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('chat role eligible with only thoughts', () => {
|
|
277
|
+
const tc = makeTc();
|
|
278
|
+
const ctx = makeConversationalCtx('chat', {
|
|
279
|
+
stepCount: 5,
|
|
280
|
+
totalTokens: 1000,
|
|
281
|
+
allSteps: [
|
|
282
|
+
{ step: 1, type: 'instruction', content: 'explain React hooks' },
|
|
283
|
+
{ step: 2, type: 'thought', content: 'explaining' },
|
|
284
|
+
{ step: 3, type: 'thought', content: 'more detail' },
|
|
285
|
+
{ step: 4, type: 'thought', content: 'examples' },
|
|
286
|
+
{ step: 5, type: 'resolution', content: 'done' },
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const result = tc._computeTrainingEligibility(ctx, 30);
|
|
290
|
+
assert.equal(result.eligible, true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('advisor role eligible with only thoughts', () => {
|
|
294
|
+
const tc = makeTc();
|
|
295
|
+
const ctx = makeConversationalCtx('advisor', {
|
|
296
|
+
stepCount: 3,
|
|
297
|
+
totalTokens: 800,
|
|
298
|
+
allSteps: [
|
|
299
|
+
{ step: 1, type: 'instruction', content: 'review approach' },
|
|
300
|
+
{ step: 2, type: 'thought', content: 'analysis' },
|
|
301
|
+
{ step: 3, type: 'resolution', content: 'recommendation' },
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
const result = tc._computeTrainingEligibility(ctx, 20);
|
|
305
|
+
assert.equal(result.eligible, true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('planner still requires minimum tokens', () => {
|
|
309
|
+
const tc = makeTc();
|
|
310
|
+
const ctx = makeConversationalCtx('planner', {
|
|
311
|
+
stepCount: 10,
|
|
312
|
+
totalTokens: 100,
|
|
313
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
|
|
314
|
+
});
|
|
315
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
316
|
+
assert.equal(result.eligible, false);
|
|
317
|
+
assert.equal(result.exclusionReason, 'insufficient_tokens');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('planner still requires minimum duration', () => {
|
|
321
|
+
const tc = makeTc();
|
|
322
|
+
const ctx = makeConversationalCtx('planner', {
|
|
323
|
+
stepCount: 10,
|
|
324
|
+
totalTokens: 2000,
|
|
325
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'plan' })),
|
|
326
|
+
});
|
|
327
|
+
const result = tc._computeTrainingEligibility(ctx, 5);
|
|
328
|
+
assert.equal(result.eligible, false);
|
|
329
|
+
assert.equal(result.exclusionReason, 'too_short');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('planner requires at least 2 steps', () => {
|
|
333
|
+
const tc = makeTc();
|
|
334
|
+
const ctx = makeConversationalCtx('planner', {
|
|
335
|
+
stepCount: 1,
|
|
336
|
+
totalTokens: 2000,
|
|
337
|
+
allSteps: [{ step: 1, type: 'thought', content: 'plan' }],
|
|
338
|
+
});
|
|
339
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
340
|
+
assert.equal(result.eligible, false);
|
|
223
341
|
assert.equal(result.exclusionReason, 'too_few_steps');
|
|
224
342
|
});
|
|
343
|
+
|
|
344
|
+
it('coding role (fullstack) still requires actions and observations', () => {
|
|
345
|
+
const tc = makeTc();
|
|
346
|
+
const ctx = makeConversationalCtx('fullstack', {
|
|
347
|
+
stepCount: 10,
|
|
348
|
+
totalTokens: 2000,
|
|
349
|
+
allSteps: Array.from({ length: 10 }, (_, i) => ({ step: i + 1, type: 'thought', content: 'thinking' })),
|
|
350
|
+
});
|
|
351
|
+
const result = tc._computeTrainingEligibility(ctx, 60);
|
|
352
|
+
assert.equal(result.eligible, false);
|
|
353
|
+
assert.equal(result.exclusionReason, 'no_actions');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('TrajectoryCapture — initial prompt capture', () => {
|
|
358
|
+
function makeSpawnTc() {
|
|
359
|
+
const tc = makeTc();
|
|
360
|
+
tc._enabled = true;
|
|
361
|
+
tc._scrubber = { scrub: (s) => s };
|
|
362
|
+
tc._attestation = { openSession: async () => {}, signEnvelope: (sid, e) => e };
|
|
363
|
+
tc._transmissionQueue = { enqueue: () => {} };
|
|
364
|
+
tc._domainTagger = null;
|
|
365
|
+
return tc;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it('onAgentSpawn records prompt as instruction step', async () => {
|
|
369
|
+
const tc = makeSpawnTc();
|
|
370
|
+
await tc.onAgentSpawn('agent-p1', 'claude-code', 'opus', 'planner', 1, 'Build a React app');
|
|
371
|
+
|
|
372
|
+
const ctx = tc._contexts.get('agent-p1');
|
|
373
|
+
assert.ok(ctx);
|
|
374
|
+
assert.equal(ctx.stepCount, 1);
|
|
375
|
+
assert.equal(ctx.allSteps[0].type, 'instruction');
|
|
376
|
+
assert.ok(ctx.allSteps[0].content.includes('Build a React app'));
|
|
377
|
+
assert.equal(ctx.allSteps[0].source, 'user');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('onAgentSpawn with no prompt creates no instruction step', async () => {
|
|
381
|
+
const tc = makeSpawnTc();
|
|
382
|
+
await tc.onAgentSpawn('agent-p2', 'claude-code', 'opus', 'fullstack', 1);
|
|
383
|
+
|
|
384
|
+
const ctx = tc._contexts.get('agent-p2');
|
|
385
|
+
assert.ok(ctx);
|
|
386
|
+
assert.equal(ctx.stepCount, 0);
|
|
387
|
+
assert.equal(ctx.allSteps.length, 0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('onAgentSpawn truncates long prompts', async () => {
|
|
391
|
+
const tc = makeSpawnTc();
|
|
392
|
+
const longPrompt = 'x'.repeat(50000);
|
|
393
|
+
await tc.onAgentSpawn('agent-p3', 'claude-code', 'opus', 'planner', 1, longPrompt);
|
|
394
|
+
|
|
395
|
+
const ctx = tc._contexts.get('agent-p3');
|
|
396
|
+
assert.ok(ctx.allSteps[0].content.length <= 10001);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('onAgentSpawn ignores empty/whitespace prompts', async () => {
|
|
400
|
+
const tc = makeSpawnTc();
|
|
401
|
+
await tc.onAgentSpawn('agent-p4', 'claude-code', 'opus', 'planner', 1, ' ');
|
|
402
|
+
|
|
403
|
+
const ctx = tc._contexts.get('agent-p4');
|
|
404
|
+
assert.equal(ctx.stepCount, 0);
|
|
405
|
+
});
|
|
225
406
|
});
|
|
226
407
|
|
|
227
408
|
describe('TrajectoryCapture — user feedback emission', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.117",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -330,6 +330,7 @@ export class ProcessManager {
|
|
|
330
330
|
this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
|
|
331
331
|
this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
|
|
332
332
|
this._exitHandled = new Set();
|
|
333
|
+
this._resultReceived = new Set();
|
|
333
334
|
|
|
334
335
|
this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
|
|
335
336
|
if (this._stallWatchdog.unref) this._stallWatchdog.unref();
|
|
@@ -389,6 +390,7 @@ export class ProcessManager {
|
|
|
389
390
|
this._exitHandled.add(agentId);
|
|
390
391
|
setTimeout(() => this._exitHandled.delete(agentId), 30_000);
|
|
391
392
|
this._stalledAgents.delete(agentId);
|
|
393
|
+
this._resultReceived.delete(agentId);
|
|
392
394
|
const throttle = this._streamThrottle.get(agentId);
|
|
393
395
|
if (throttle?.timer) clearTimeout(throttle.timer);
|
|
394
396
|
this._streamThrottle.delete(agentId);
|
|
@@ -425,11 +427,16 @@ export class ProcessManager {
|
|
|
425
427
|
|
|
426
428
|
if (this.daemon.locks) this.daemon.locks.release(agent.id);
|
|
427
429
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
430
|
+
const hadResult = this._resultReceived.has(agent.id);
|
|
431
|
+
this._resultReceived.delete(agent.id);
|
|
432
|
+
|
|
433
|
+
const finalStatus = hadResult
|
|
434
|
+
? 'completed'
|
|
435
|
+
: signal === 'SIGTERM' || signal === 'SIGKILL'
|
|
436
|
+
? 'killed'
|
|
437
|
+
: code === 0
|
|
438
|
+
? 'completed'
|
|
439
|
+
: 'crashed';
|
|
433
440
|
|
|
434
441
|
const crashError = finalStatus === 'crashed' ? stderrBuf.join('').trim().slice(-500) : null;
|
|
435
442
|
|
|
@@ -552,7 +559,10 @@ export class ProcessManager {
|
|
|
552
559
|
|
|
553
560
|
if (this.daemon.locks) this.daemon.locks.release(agent.id);
|
|
554
561
|
|
|
555
|
-
const
|
|
562
|
+
const hadResult = this._resultReceived.has(agent.id);
|
|
563
|
+
this._resultReceived.delete(agent.id);
|
|
564
|
+
|
|
565
|
+
const finalStatus = hadResult ? 'completed' : signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
|
|
556
566
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
557
567
|
|
|
558
568
|
if (this.daemon.trajectoryCapture) {
|
|
@@ -727,7 +737,7 @@ export class ProcessManager {
|
|
|
727
737
|
try {
|
|
728
738
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
729
739
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
730
|
-
agent.id, providerName, config.model || null, config.role, teamSize
|
|
740
|
+
agent.id, providerName, config.model || null, config.role, teamSize, config.prompt
|
|
731
741
|
).catch(() => {});
|
|
732
742
|
} catch (e) { /* fail silent */ }
|
|
733
743
|
}
|
|
@@ -970,6 +980,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
970
980
|
logStream.end();
|
|
971
981
|
this.handles.delete(agent.id);
|
|
972
982
|
this._stalledAgents.delete(agent.id);
|
|
983
|
+
this._resultReceived.delete(agent.id);
|
|
973
984
|
|
|
974
985
|
// Clean up stream throttle so pending timers don't fire for dead agents
|
|
975
986
|
const throttle = this._streamThrottle.get(agent.id);
|
|
@@ -1262,6 +1273,21 @@ For normal file edits within your scope, proceed without review.
|
|
|
1262
1273
|
if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
|
|
1263
1274
|
if (output.duration) updates.durationMs = output.duration;
|
|
1264
1275
|
if (output.turns) updates.turns = output.turns;
|
|
1276
|
+
|
|
1277
|
+
// Claude Code sometimes hangs after emitting the result event — the
|
|
1278
|
+
// process stays alive instead of exiting. Record that the result
|
|
1279
|
+
// arrived so exit handlers know this was a successful completion even
|
|
1280
|
+
// if we have to SIGTERM the process. After a 5s grace period, force-
|
|
1281
|
+
// kill any process that hasn't exited on its own.
|
|
1282
|
+
this._resultReceived.add(agentId);
|
|
1283
|
+
const handle = this.handles.get(agentId);
|
|
1284
|
+
if (handle?.proc && typeof handle.proc.kill === 'function') {
|
|
1285
|
+
setTimeout(() => {
|
|
1286
|
+
if (this.handles.has(agentId) && this._resultReceived.has(agentId)) {
|
|
1287
|
+
try { handle.proc.kill('SIGTERM'); } catch {}
|
|
1288
|
+
}
|
|
1289
|
+
}, 5_000);
|
|
1290
|
+
}
|
|
1265
1291
|
}
|
|
1266
1292
|
|
|
1267
1293
|
// Context window usage (0-1 scale) — drives rotation threshold
|
|
@@ -1845,7 +1871,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1845
1871
|
try {
|
|
1846
1872
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
1847
1873
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
1848
|
-
newAgent.id, config.provider, config.model || null, config.role, teamSize
|
|
1874
|
+
newAgent.id, config.provider, config.model || null, config.role, teamSize, config.prompt
|
|
1849
1875
|
).catch(() => {});
|
|
1850
1876
|
} catch (e) { /* fail silent */ }
|
|
1851
1877
|
}
|
|
@@ -1987,7 +2013,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1987
2013
|
try {
|
|
1988
2014
|
const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
|
|
1989
2015
|
this.daemon.trajectoryCapture.onAgentSpawn(
|
|
1990
|
-
newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize
|
|
2016
|
+
newAgent.id, config.provider, loopConfig.model || config.model || null, config.role, teamSize, config.prompt
|
|
1991
2017
|
).catch(() => {});
|
|
1992
2018
|
} catch (e) { /* fail silent */ }
|
|
1993
2019
|
}
|
|
@@ -274,9 +274,11 @@ export class TunnelManager {
|
|
|
274
274
|
throw new Error(testResult.error || 'Host unreachable');
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
let preConnectHandled = false;
|
|
277
278
|
if (!testResult.daemonRunning && !testResult.grooveInstalled) {
|
|
278
279
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
|
|
279
280
|
await this.remoteInstall(id);
|
|
281
|
+
preConnectHandled = true;
|
|
280
282
|
} else if (!testResult.daemonRunning && testResult.grooveInstalled) {
|
|
281
283
|
const localVer = getLocalVersion();
|
|
282
284
|
if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
|
|
@@ -285,6 +287,7 @@ export class TunnelManager {
|
|
|
285
287
|
}
|
|
286
288
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
287
289
|
await this.autoStart(id);
|
|
290
|
+
preConnectHandled = true;
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
@@ -342,7 +345,9 @@ export class TunnelManager {
|
|
|
342
345
|
failCount: 0,
|
|
343
346
|
});
|
|
344
347
|
|
|
345
|
-
|
|
348
|
+
if (!preConnectHandled) {
|
|
349
|
+
await this._checkAndUpgradeRunning(id, config, localPort);
|
|
350
|
+
}
|
|
346
351
|
|
|
347
352
|
try {
|
|
348
353
|
const statusResp = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -473,6 +478,18 @@ export class TunnelManager {
|
|
|
473
478
|
|
|
474
479
|
this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
|
|
475
480
|
} catch (err) {
|
|
481
|
+
try {
|
|
482
|
+
const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
|
|
483
|
+
if (verify.ok) {
|
|
484
|
+
const verifyData = await verify.json();
|
|
485
|
+
if (verifyData.version === localVer) {
|
|
486
|
+
this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
} catch { /* tunnel verification failed */ }
|
|
476
493
|
this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: err.message } });
|
|
477
494
|
}
|
|
478
495
|
}
|