@ours.network/fleet 0.11.1 → 0.13.0
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/README.md +225 -0
- package/dist/briefing.js +25 -0
- package/dist/cli.js +408 -1
- package/dist/config.d.ts +31 -1
- package/dist/config.js +123 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +143 -0
- package/dist/duration.js +7 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/loops/config.d.ts +30 -0
- package/dist/loops/config.js +135 -0
- package/dist/loops/manager.d.ts +48 -0
- package/dist/loops/manager.js +237 -0
- package/dist/loops/state.d.ts +54 -0
- package/dist/loops/state.js +148 -0
- package/dist/monitor.js +26 -2
- package/dist/owner-channel/attachments.d.ts +74 -0
- package/dist/owner-channel/attachments.js +378 -0
- package/dist/owner-channel/channel.d.ts +167 -0
- package/dist/owner-channel/channel.js +874 -0
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +123 -0
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +44 -0
- package/dist/owner-channel/state.js +184 -0
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +12 -0
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +112 -5
- package/dist/session/acp.d.ts +4 -2
- package/dist/session/acp.js +82 -25
- package/dist/session/arbiter.d.ts +42 -0
- package/dist/session/arbiter.js +72 -0
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +56 -3
- package/dist/session/types.d.ts +28 -2
- package/dist/session/types.js +5 -2
- package/dist/spawn.js +7 -3
- package/dist/supervisor/systemd.js +12 -2
- package/package.json +1 -1
package/dist/session/acp.js
CHANGED
|
@@ -40,6 +40,7 @@ export class AcpSession {
|
|
|
40
40
|
steeringSupported = false;
|
|
41
41
|
capabilities;
|
|
42
42
|
controllerCount = 0;
|
|
43
|
+
activeTurn;
|
|
43
44
|
constructor(options, child, connection) {
|
|
44
45
|
this.options = options;
|
|
45
46
|
this.child = child;
|
|
@@ -117,24 +118,24 @@ export class AcpSession {
|
|
|
117
118
|
if (!this.sessionId || !this.isAlive())
|
|
118
119
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
119
120
|
if (options.interrupt)
|
|
120
|
-
await this.
|
|
121
|
+
await this.cancelActive(options.interruptSource ?? 'local-console');
|
|
121
122
|
// Interrupting delivery must still use steering when supported. With no
|
|
122
123
|
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
123
124
|
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
124
125
|
// the entire wake-triggered turn terminated.
|
|
125
126
|
if (options.steer && this.steeringSupported) {
|
|
126
127
|
const promptId = randomUUID();
|
|
127
|
-
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text) };
|
|
128
|
+
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
|
|
128
129
|
}
|
|
129
130
|
const promptId = randomUUID();
|
|
130
131
|
const queuedBehind = this.queueDepth++;
|
|
131
|
-
const run = this.promptTail.then(() => this.runPrompt(text, promptId));
|
|
132
|
+
const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
|
|
132
133
|
this.promptTail = run.then(() => undefined, () => undefined);
|
|
133
134
|
const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
|
|
134
135
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
135
136
|
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
136
137
|
});
|
|
137
|
-
return { promptId, queuedBehind, completion };
|
|
138
|
+
return { promptId, queuedBehind, completion, origin: options.origin };
|
|
138
139
|
}
|
|
139
140
|
async submitPrompt(text, options = {}) {
|
|
140
141
|
try {
|
|
@@ -146,10 +147,24 @@ export class AcpSession {
|
|
|
146
147
|
throw error;
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
|
-
async interrupt() {
|
|
150
|
+
async interrupt(source = 'local-console') {
|
|
151
|
+
await this.cancelActive(source);
|
|
152
|
+
}
|
|
153
|
+
async cancelActive(source) {
|
|
150
154
|
if (!this.sessionId)
|
|
151
155
|
return;
|
|
152
|
-
|
|
156
|
+
const active = this.activeTurn;
|
|
157
|
+
const previousSource = active?.cancellationSource;
|
|
158
|
+
if (active && (source === 'owner' || source === 'local-console' || !previousSource))
|
|
159
|
+
active.cancellationSource = source;
|
|
160
|
+
try {
|
|
161
|
+
await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (this.activeTurn === active && active?.cancellationSource === source)
|
|
165
|
+
active.cancellationSource = previousSource;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
153
168
|
for (const pending of this.pendingPermissions.values())
|
|
154
169
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
155
170
|
this.pendingPermissions.clear();
|
|
@@ -162,6 +177,8 @@ export class AcpSession {
|
|
|
162
177
|
this.pendingPermissions.delete(permissionId);
|
|
163
178
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
164
179
|
this.events.emit('permission', {
|
|
180
|
+
turnId: this.activeTurn?.id,
|
|
181
|
+
origin: this.activeTurn?.origin,
|
|
165
182
|
permissionId,
|
|
166
183
|
status: 'completed',
|
|
167
184
|
decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
|
|
@@ -237,30 +254,43 @@ export class AcpSession {
|
|
|
237
254
|
this.readiness = 'idle';
|
|
238
255
|
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
239
256
|
}
|
|
240
|
-
async runPrompt(text, turnId = randomUUID()) {
|
|
257
|
+
async runPrompt(text, turnId = randomUUID(), origin) {
|
|
241
258
|
if (!this.sessionId || !this.isAlive())
|
|
242
259
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
243
260
|
this.readiness = 'running';
|
|
244
|
-
this.
|
|
261
|
+
this.activeTurn = { id: turnId, output: '', origin };
|
|
262
|
+
this.events.emit('state', { turnId, status: 'running', origin });
|
|
245
263
|
try {
|
|
246
264
|
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
247
265
|
sessionId: this.sessionId,
|
|
248
266
|
prompt: [{ type: 'text', text }],
|
|
249
267
|
});
|
|
250
268
|
this.readiness = 'idle';
|
|
251
|
-
this.events.emit('turn_stop', {
|
|
269
|
+
this.events.emit('turn_stop', {
|
|
270
|
+
turnId, stopReason: response.stopReason, origin,
|
|
271
|
+
cancellationSource: this.activeTurn?.id === turnId
|
|
272
|
+
? this.activeTurn.cancellationSource : undefined,
|
|
273
|
+
});
|
|
252
274
|
this.events.emit('state', { status: 'idle' });
|
|
253
275
|
// The prompt was accepted either way — the agent answered. Whether the
|
|
254
276
|
// turn SUCCEEDED is a separate question, and only `stopReason` answers it.
|
|
255
|
-
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason);
|
|
277
|
+
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
|
|
256
278
|
}
|
|
257
279
|
catch (error) {
|
|
258
|
-
|
|
280
|
+
const detail = error?.message ?? String(error);
|
|
281
|
+
this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
|
|
259
282
|
this.readiness = this.isAlive() ? 'idle' : 'failed';
|
|
260
|
-
this.events.emit('error', {
|
|
283
|
+
this.events.emit('error', {
|
|
284
|
+
turnId, origin,
|
|
285
|
+
text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
|
|
286
|
+
});
|
|
261
287
|
if (this.isAlive())
|
|
262
288
|
this.events.emit('state', { status: 'idle' });
|
|
263
|
-
return turnResult(false, 'failed', this.lastError);
|
|
289
|
+
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (this.activeTurn?.id === turnId)
|
|
293
|
+
this.activeTurn = undefined;
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
296
|
async steerPrompt(text) {
|
|
@@ -312,12 +342,18 @@ export class AcpSession {
|
|
|
312
342
|
const permissionId = randomUUID();
|
|
313
343
|
this.readiness = 'awaiting_permission';
|
|
314
344
|
this.events.emit('permission', {
|
|
345
|
+
turnId: this.activeTurn?.id,
|
|
346
|
+
origin: this.activeTurn?.origin,
|
|
315
347
|
permissionId,
|
|
316
|
-
toolCallId:
|
|
317
|
-
|
|
348
|
+
toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
349
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
350
|
+
title: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
351
|
+
? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
|
|
318
352
|
status: 'pending',
|
|
319
353
|
options: params.options.map(option => ({
|
|
320
|
-
optionId: option.optionId,
|
|
354
|
+
optionId: option.optionId,
|
|
355
|
+
name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
|
|
356
|
+
kind: option.kind,
|
|
321
357
|
})),
|
|
322
358
|
});
|
|
323
359
|
return new Promise(resolve => {
|
|
@@ -332,16 +368,24 @@ export class AcpSession {
|
|
|
332
368
|
settleAutomatically(params, option, decision, policy, reason) {
|
|
333
369
|
const settled = option ? decision : 'cancelled';
|
|
334
370
|
this.events.emit('permission', {
|
|
371
|
+
turnId: this.activeTurn?.id,
|
|
372
|
+
origin: this.activeTurn?.origin,
|
|
335
373
|
permissionId: randomUUID(),
|
|
336
|
-
toolCallId:
|
|
337
|
-
|
|
374
|
+
toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
375
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
338
376
|
status: 'completed',
|
|
339
377
|
decision: settled,
|
|
340
378
|
decisionSource: 'automatic',
|
|
341
379
|
policy,
|
|
342
380
|
reason: option ? reason : `${reason}, but the agent offered no matching option`,
|
|
343
381
|
optionId: option?.optionId,
|
|
344
|
-
|
|
382
|
+
title: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
383
|
+
? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
|
|
384
|
+
options: params.options.map(o => ({
|
|
385
|
+
optionId: o.optionId,
|
|
386
|
+
name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? o.kind : o.name,
|
|
387
|
+
kind: o.kind,
|
|
388
|
+
})),
|
|
345
389
|
});
|
|
346
390
|
return option
|
|
347
391
|
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
@@ -364,28 +408,41 @@ export class AcpSession {
|
|
|
364
408
|
});
|
|
365
409
|
}
|
|
366
410
|
recordUpdate(update) {
|
|
411
|
+
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
367
412
|
switch (update.sessionUpdate) {
|
|
368
413
|
case 'agent_message_chunk':
|
|
414
|
+
if (this.activeTurn && update.content.type === 'text')
|
|
415
|
+
this.activeTurn.output += update.content.text;
|
|
369
416
|
this.events.emit('agent_text', {
|
|
370
|
-
|
|
417
|
+
turnId: this.activeTurn?.id,
|
|
418
|
+
origin: this.activeTurn?.origin,
|
|
419
|
+
text: scheduled ? '[scheduled-loop output redacted]'
|
|
420
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
371
421
|
});
|
|
372
422
|
break;
|
|
373
423
|
case 'agent_thought_chunk':
|
|
374
424
|
this.events.emit('thought', {
|
|
375
|
-
|
|
425
|
+
turnId: this.activeTurn?.id,
|
|
426
|
+
origin: this.activeTurn?.origin,
|
|
427
|
+
text: scheduled ? '[scheduled-loop thought redacted]'
|
|
428
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
376
429
|
});
|
|
377
430
|
break;
|
|
378
431
|
case 'tool_call':
|
|
379
432
|
this.events.emit('tool_call', {
|
|
380
|
-
|
|
381
|
-
|
|
433
|
+
turnId: this.activeTurn?.id,
|
|
434
|
+
origin: this.activeTurn?.origin,
|
|
435
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
436
|
+
title: scheduled ? 'scheduled-loop tool' : update.title,
|
|
382
437
|
status: update.status,
|
|
383
438
|
});
|
|
384
439
|
break;
|
|
385
440
|
case 'tool_call_update':
|
|
386
441
|
this.events.emit('tool_update', {
|
|
387
|
-
|
|
388
|
-
|
|
442
|
+
turnId: this.activeTurn?.id,
|
|
443
|
+
origin: this.activeTurn?.origin,
|
|
444
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
445
|
+
title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
|
|
389
446
|
status: update.status ?? undefined,
|
|
390
447
|
});
|
|
391
448
|
break;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ExitRecord, PromptOrigin, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
|
|
2
|
+
export type ScheduledAttempt = {
|
|
3
|
+
state: 'started';
|
|
4
|
+
queued: QueuedPrompt;
|
|
5
|
+
} | {
|
|
6
|
+
state: 'skipped_busy';
|
|
7
|
+
} | {
|
|
8
|
+
state: 'unavailable';
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One in-process admission boundary for every producer targeting a role.
|
|
13
|
+
* Scheduled callers get an atomic idle recheck plus submission; ordinary
|
|
14
|
+
* producers retain ACP queue semantics while making their unsettled claim
|
|
15
|
+
* visible before another producer can inspect idle state.
|
|
16
|
+
*/
|
|
17
|
+
export declare class RoleTurnArbiter implements SessionHandle {
|
|
18
|
+
private readonly session;
|
|
19
|
+
readonly backend: import("../config.js").SessionBackendId;
|
|
20
|
+
readonly pid: number;
|
|
21
|
+
private tail;
|
|
22
|
+
private unsettled;
|
|
23
|
+
private stopping;
|
|
24
|
+
constructor(session: SessionHandle);
|
|
25
|
+
private exclusive;
|
|
26
|
+
private track;
|
|
27
|
+
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
28
|
+
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
29
|
+
tryScheduled(text: string, origin: Extract<PromptOrigin, {
|
|
30
|
+
kind: 'scheduled-loop';
|
|
31
|
+
}>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
|
|
32
|
+
stopScheduledAdmission(): void;
|
|
33
|
+
isAlive(): boolean;
|
|
34
|
+
snapshot(): SessionSnapshot;
|
|
35
|
+
interrupt(source?: TurnCancellationSource): Promise<void>;
|
|
36
|
+
respondPermission(permissionId: string, optionId: string): boolean;
|
|
37
|
+
eventsSince(seq: number): SessionEvent[];
|
|
38
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
39
|
+
setControllerAttached(attached: boolean): void;
|
|
40
|
+
exitResult(): ExitRecord | null;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One in-process admission boundary for every producer targeting a role.
|
|
3
|
+
* Scheduled callers get an atomic idle recheck plus submission; ordinary
|
|
4
|
+
* producers retain ACP queue semantics while making their unsettled claim
|
|
5
|
+
* visible before another producer can inspect idle state.
|
|
6
|
+
*/
|
|
7
|
+
export class RoleTurnArbiter {
|
|
8
|
+
session;
|
|
9
|
+
backend;
|
|
10
|
+
pid;
|
|
11
|
+
tail = Promise.resolve();
|
|
12
|
+
unsettled = 0;
|
|
13
|
+
stopping = false;
|
|
14
|
+
constructor(session) {
|
|
15
|
+
this.session = session;
|
|
16
|
+
this.backend = session.backend;
|
|
17
|
+
this.pid = session.pid;
|
|
18
|
+
}
|
|
19
|
+
exclusive(operation) {
|
|
20
|
+
const run = this.tail.then(operation);
|
|
21
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
22
|
+
return run;
|
|
23
|
+
}
|
|
24
|
+
track(queued) {
|
|
25
|
+
this.unsettled++;
|
|
26
|
+
const completion = queued.completion.finally(() => { this.unsettled = Math.max(0, this.unsettled - 1); });
|
|
27
|
+
return { ...queued, completion };
|
|
28
|
+
}
|
|
29
|
+
queuePrompt(text, options = {}) {
|
|
30
|
+
return this.exclusive(async () => this.track(await this.session.queuePrompt(text, options)));
|
|
31
|
+
}
|
|
32
|
+
async submitPrompt(text, options = {}) {
|
|
33
|
+
return (await this.queuePrompt(text, options)).completion;
|
|
34
|
+
}
|
|
35
|
+
async tryScheduled(text, origin, beforeQueue) {
|
|
36
|
+
// Give owner/console/I/O callbacks already ready in this event-loop turn a
|
|
37
|
+
// chance to claim the arbiter first. Scheduled work is best-effort; humans
|
|
38
|
+
// and authenticated ingress have priority at the idle boundary.
|
|
39
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
40
|
+
return this.exclusive(async () => {
|
|
41
|
+
const snapshot = this.session.snapshot();
|
|
42
|
+
if (this.stopping || !snapshot.alive || snapshot.readiness === 'failed')
|
|
43
|
+
return { state: 'unavailable', error: this.stopping ? 'role is stopping' : 'session is unavailable' };
|
|
44
|
+
if (snapshot.readiness !== 'idle' || this.unsettled > 0)
|
|
45
|
+
return { state: 'skipped_busy' };
|
|
46
|
+
try {
|
|
47
|
+
await beforeQueue?.();
|
|
48
|
+
const queued = await this.session.queuePrompt(text, { interrupt: false, origin });
|
|
49
|
+
if (queued.queuedBehind > 0)
|
|
50
|
+
return { state: 'unavailable', error: 'scheduled admission race' };
|
|
51
|
+
return { state: 'started', queued: this.track(queued) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { state: 'unavailable', error: error?.message ?? String(error) };
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
stopScheduledAdmission() { this.stopping = true; }
|
|
59
|
+
isAlive() { return this.session.isAlive(); }
|
|
60
|
+
snapshot() { return this.session.snapshot(); }
|
|
61
|
+
interrupt(source = 'local-console') {
|
|
62
|
+
return this.exclusive(() => this.session.interrupt(source));
|
|
63
|
+
}
|
|
64
|
+
respondPermission(permissionId, optionId) {
|
|
65
|
+
return this.session.respondPermission(permissionId, optionId);
|
|
66
|
+
}
|
|
67
|
+
eventsSince(seq) { return this.session.eventsSince(seq); }
|
|
68
|
+
subscribe(listener) { return this.session.subscribe(listener); }
|
|
69
|
+
setControllerAttached(attached) { this.session.setControllerAttached(attached); }
|
|
70
|
+
exitResult() { return this.session.exitResult(); }
|
|
71
|
+
close() { return this.session.close(); }
|
|
72
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { type Socket } from 'node:net';
|
|
2
2
|
import type { ControlFailureKind, SessionHandle } from './types.js';
|
|
3
|
+
import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
|
|
4
|
+
import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
|
|
3
5
|
export interface ControlRequest {
|
|
4
6
|
version: 1 | 2;
|
|
5
7
|
id: string;
|
|
6
8
|
token: string;
|
|
7
|
-
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since';
|
|
9
|
+
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config';
|
|
8
10
|
text?: string;
|
|
9
11
|
permissionId?: string;
|
|
10
12
|
optionId?: string;
|
|
11
13
|
since?: number;
|
|
12
14
|
/** Existing clients omit this and remain interactive controllers. */
|
|
13
15
|
controller?: boolean;
|
|
16
|
+
ownerChannel?: OwnerChannelManagementRequest;
|
|
17
|
+
loop?: string;
|
|
14
18
|
}
|
|
15
19
|
export interface ControlResponse {
|
|
16
20
|
version: 1;
|
|
@@ -71,9 +75,16 @@ export declare class RoleControlServer {
|
|
|
71
75
|
private readonly socketPath;
|
|
72
76
|
private readonly token;
|
|
73
77
|
private readonly sockets;
|
|
78
|
+
private ownerChannel?;
|
|
79
|
+
private loopManager?;
|
|
80
|
+
private reloadConfig?;
|
|
74
81
|
constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
|
|
75
82
|
start(): Promise<void>;
|
|
76
83
|
close(): Promise<void>;
|
|
84
|
+
/** Attach only the already-started supervisor-owned channel client. */
|
|
85
|
+
setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
|
|
86
|
+
setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
|
|
87
|
+
setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
|
|
77
88
|
private accept;
|
|
78
89
|
private handle;
|
|
79
90
|
private write;
|
package/dist/session/control.js
CHANGED
|
@@ -95,6 +95,9 @@ export class RoleControlServer {
|
|
|
95
95
|
socketPath;
|
|
96
96
|
token;
|
|
97
97
|
sockets = new Set();
|
|
98
|
+
ownerChannel;
|
|
99
|
+
loopManager;
|
|
100
|
+
reloadConfig;
|
|
98
101
|
constructor(stateDir, session, log) {
|
|
99
102
|
this.session = session;
|
|
100
103
|
this.log = log;
|
|
@@ -127,6 +130,16 @@ export class RoleControlServer {
|
|
|
127
130
|
await new Promise(resolve => this.server.close(() => resolve()));
|
|
128
131
|
rmSync(this.socketPath, { force: true });
|
|
129
132
|
}
|
|
133
|
+
/** Attach only the already-started supervisor-owned channel client. */
|
|
134
|
+
setOwnerChannel(ownerChannel) {
|
|
135
|
+
this.ownerChannel = ownerChannel;
|
|
136
|
+
}
|
|
137
|
+
setLoopManager(loopManager) {
|
|
138
|
+
this.loopManager = loopManager;
|
|
139
|
+
}
|
|
140
|
+
setConfigReloader(reloadConfig) {
|
|
141
|
+
this.reloadConfig = reloadConfig;
|
|
142
|
+
}
|
|
130
143
|
accept(socket) {
|
|
131
144
|
this.sockets.add(socket);
|
|
132
145
|
socket.setEncoding('utf8');
|
|
@@ -202,7 +215,9 @@ export class RoleControlServer {
|
|
|
202
215
|
// Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
|
|
203
216
|
// for minutes; blocking here made every `send` into a busy agent time
|
|
204
217
|
// out, and the timeout was then reported as a dead agent.
|
|
205
|
-
const queued = await this.session.queuePrompt(request.text
|
|
218
|
+
const queued = await this.session.queuePrompt(request.text, {
|
|
219
|
+
origin: { kind: 'local-console' },
|
|
220
|
+
});
|
|
206
221
|
this.write(socket, {
|
|
207
222
|
version: 1, id: request.id, ok: true,
|
|
208
223
|
result: {
|
|
@@ -224,9 +239,47 @@ export class RoleControlServer {
|
|
|
224
239
|
return;
|
|
225
240
|
}
|
|
226
241
|
case 'interrupt':
|
|
227
|
-
await this.session.interrupt();
|
|
242
|
+
await this.session.interrupt('local-console');
|
|
228
243
|
this.write(socket, { version: 1, id: request.id, ok: true });
|
|
229
244
|
return;
|
|
245
|
+
case 'loop_status': {
|
|
246
|
+
if (!this.loopManager)
|
|
247
|
+
throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
|
|
248
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: this.loopManager.status() });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
case 'loop_run_now':
|
|
252
|
+
case 'loop_disable':
|
|
253
|
+
case 'loop_enable': {
|
|
254
|
+
if (request.version !== 2 || !request.loop)
|
|
255
|
+
throw new SessionControlError('rejected', 'version 2 and loop name are required');
|
|
256
|
+
if (!this.loopManager)
|
|
257
|
+
throw new SessionControlError('rejected', 'scheduled loops are unavailable for this role');
|
|
258
|
+
const result = request.command === 'loop_run_now'
|
|
259
|
+
? await this.loopManager.runNow(request.loop)
|
|
260
|
+
: request.command === 'loop_disable'
|
|
261
|
+
? this.loopManager.disable(request.loop)
|
|
262
|
+
: this.loopManager.enable(request.loop);
|
|
263
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case 'reload_config': {
|
|
267
|
+
if (request.version !== 2 || !this.reloadConfig)
|
|
268
|
+
throw new SessionControlError('rejected', 'config reload is unavailable for this role');
|
|
269
|
+
this.write(socket, {
|
|
270
|
+
version: 1, id: request.id, ok: true, result: await this.reloadConfig(),
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case 'owner_channel_manage': {
|
|
275
|
+
if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
|
|
276
|
+
throw new SessionControlError('rejected', 'owner-channel management action is required');
|
|
277
|
+
if (!this.ownerChannel)
|
|
278
|
+
throw new SessionControlError('rejected', 'owner channel is disabled or unavailable for this role');
|
|
279
|
+
const result = await this.ownerChannel.manage(request.ownerChannel);
|
|
280
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
230
283
|
case 'events_since': {
|
|
231
284
|
const since = Number.isFinite(request.since) ? Number(request.since) : 0;
|
|
232
285
|
const events = this.session.eventsSince(since);
|
|
@@ -323,7 +376,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
|
|
|
323
376
|
socket.end();
|
|
324
377
|
});
|
|
325
378
|
socket.once('connect', () => socket.write(JSON.stringify({
|
|
326
|
-
version:
|
|
379
|
+
version: 2, id, token, ...request,
|
|
327
380
|
}) + '\n'));
|
|
328
381
|
});
|
|
329
382
|
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { SessionBackendId } from '../config.js';
|
|
2
2
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
3
3
|
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
4
|
+
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
|
|
5
|
+
export type PromptOrigin = {
|
|
6
|
+
kind: 'startup';
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'local-console';
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'owner';
|
|
11
|
+
requestId: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'fleet-monitor';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'scheduled-loop';
|
|
16
|
+
loop: string;
|
|
17
|
+
runId: string;
|
|
18
|
+
};
|
|
4
19
|
/**
|
|
5
20
|
* Two independent facts about one turn, deliberately kept apart:
|
|
6
21
|
*
|
|
@@ -19,6 +34,10 @@ export interface TurnResult {
|
|
|
19
34
|
outcome: TurnOutcome;
|
|
20
35
|
succeeded: boolean;
|
|
21
36
|
detail?: string;
|
|
37
|
+
/** Present only when fleet can prove what initiated a cancelled turn. */
|
|
38
|
+
cancellationSource?: TurnCancellationSource;
|
|
39
|
+
/** Final assistant text captured structurally by a backend, when available. */
|
|
40
|
+
output?: string;
|
|
22
41
|
}
|
|
23
42
|
/**
|
|
24
43
|
* Why a control operation failed. The distinctions exist because collapsing
|
|
@@ -39,6 +58,7 @@ export interface QueuedPrompt {
|
|
|
39
58
|
promptId: string;
|
|
40
59
|
/** Turns already queued ahead of this one. 0 means it starts immediately. */
|
|
41
60
|
queuedBehind: number;
|
|
61
|
+
origin?: PromptOrigin;
|
|
42
62
|
/** The turn's terminal result. Never rejects. */
|
|
43
63
|
completion: Promise<TurnResult>;
|
|
44
64
|
}
|
|
@@ -75,10 +95,14 @@ export declare function classifyChildExit(code: number | null, signal: string |
|
|
|
75
95
|
/** The single definition of terminal success. Nothing else may re-derive it. */
|
|
76
96
|
export declare const isTerminalSuccess: (outcome: TurnOutcome) => boolean;
|
|
77
97
|
/** Build a TurnResult with `succeeded` always consistent with `outcome`. */
|
|
78
|
-
export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string): TurnResult;
|
|
98
|
+
export declare function turnResult(accepted: boolean, outcome: TurnOutcome, detail?: string, output?: string, cancellationSource?: TurnCancellationSource): TurnResult;
|
|
79
99
|
export interface SubmitPromptOptions {
|
|
80
100
|
/** Cancel active work before delivering this prompt. */
|
|
81
101
|
interrupt?: boolean;
|
|
102
|
+
/** Internal provenance; ordinary callers must leave this unset. */
|
|
103
|
+
interruptSource?: TurnCancellationSource;
|
|
104
|
+
/** Typed in-process provenance. Text markers never grant this origin. */
|
|
105
|
+
origin?: PromptOrigin;
|
|
82
106
|
/** Use the ACP steering extension when available; ignored by other backends. */
|
|
83
107
|
steer?: boolean;
|
|
84
108
|
}
|
|
@@ -105,6 +129,8 @@ export interface SessionEvent {
|
|
|
105
129
|
title?: string;
|
|
106
130
|
status?: string;
|
|
107
131
|
stopReason?: string;
|
|
132
|
+
origin?: PromptOrigin;
|
|
133
|
+
cancellationSource?: TurnCancellationSource;
|
|
108
134
|
options?: Array<{
|
|
109
135
|
optionId: string;
|
|
110
136
|
name: string;
|
|
@@ -133,7 +159,7 @@ export interface SessionHandle {
|
|
|
133
159
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
134
160
|
/** Queue a prompt and wait for its terminal result. */
|
|
135
161
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
136
|
-
interrupt(): Promise<void>;
|
|
162
|
+
interrupt(source?: TurnCancellationSource): Promise<void>;
|
|
137
163
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
138
164
|
eventsSince(seq: number): SessionEvent[];
|
|
139
165
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
package/dist/session/types.js
CHANGED
|
@@ -37,6 +37,9 @@ export function classifyChildExit(code, signal) {
|
|
|
37
37
|
/** The single definition of terminal success. Nothing else may re-derive it. */
|
|
38
38
|
export const isTerminalSuccess = (outcome) => outcome === 'completed';
|
|
39
39
|
/** Build a TurnResult with `succeeded` always consistent with `outcome`. */
|
|
40
|
-
export function turnResult(accepted, outcome, detail) {
|
|
41
|
-
return {
|
|
40
|
+
export function turnResult(accepted, outcome, detail, output, cancellationSource) {
|
|
41
|
+
return {
|
|
42
|
+
accepted, outcome, succeeded: isTerminalSuccess(outcome), detail, output,
|
|
43
|
+
...(outcome === 'cancelled' && cancellationSource ? { cancellationSource } : {}),
|
|
44
|
+
};
|
|
42
45
|
}
|
package/dist/spawn.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
|
-
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
|
|
7
|
+
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
|
|
8
8
|
import { applyRole, up } from './ops.js';
|
|
9
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
10
|
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
@@ -186,12 +186,13 @@ export function spawnDryRun(o) {
|
|
|
186
186
|
const defaultHarness = cfg.defaults.harness ?? 'claude-code';
|
|
187
187
|
const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
|
|
188
188
|
const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
|
|
189
|
+
const session = raw.session ?? cfg.defaults.session ?? 'tmux';
|
|
189
190
|
const resolvedRole = {
|
|
190
191
|
...raw,
|
|
191
192
|
name: o.name,
|
|
192
193
|
sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
|
|
193
194
|
harness,
|
|
194
|
-
session
|
|
195
|
+
session,
|
|
195
196
|
session_options: raw.session_options,
|
|
196
197
|
permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
|
|
197
198
|
permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
@@ -203,6 +204,7 @@ export function spawnDryRun(o) {
|
|
|
203
204
|
harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
|
|
204
205
|
isolation: raw.isolation ?? cfg.defaults.isolation,
|
|
205
206
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
|
|
207
|
+
owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
|
|
206
208
|
worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
|
|
207
209
|
auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
|
|
208
210
|
};
|
|
@@ -378,11 +380,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
|
378
380
|
const harness = o.harness ?? defaultHarness ?? 'claude-code';
|
|
379
381
|
const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
|
|
380
382
|
const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
|
|
383
|
+
const session = o.session ?? cfg.defaults.session ?? 'tmux';
|
|
381
384
|
const role = {
|
|
382
385
|
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
383
386
|
name: o.name,
|
|
384
387
|
harness,
|
|
385
|
-
session
|
|
388
|
+
session,
|
|
386
389
|
identity: o.identity ?? o.name,
|
|
387
390
|
model,
|
|
388
391
|
model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
|
|
@@ -393,6 +396,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
|
393
396
|
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
394
397
|
// Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
|
|
395
398
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
399
|
+
owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
|
|
396
400
|
worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
|
|
397
401
|
auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
|
|
398
402
|
sourceFile: '(temp)',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
3
3
|
import { userInfo } from 'node:os';
|
|
4
4
|
import { home } from '../paths.js';
|
|
5
5
|
import { realExec } from '../exec.js';
|
|
@@ -15,6 +15,8 @@ export const busHint = (stderr) => /user scope bus|XDG_RUNTIME_DIR/.test(stderr)
|
|
|
15
15
|
`\n (if linger is already on: export XDG_RUNTIME_DIR=/run/user/$(id -u))`
|
|
16
16
|
: '';
|
|
17
17
|
export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
|
|
18
|
+
/** Quote a systemd unit argument and escape its specifier marker. */
|
|
19
|
+
const unitArg = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
|
|
18
20
|
/**
|
|
19
21
|
* systemd's own ActiveState vocabulary, classified. `activating` covers
|
|
20
22
|
* `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
|
|
@@ -60,6 +62,13 @@ export function makeSystemdBackend(exec = realExec) {
|
|
|
60
62
|
async init(binPath) {
|
|
61
63
|
const msgs = [];
|
|
62
64
|
const unitDir = join(home(), '.config', 'systemd', 'user');
|
|
65
|
+
// Lingering user units often start before a login shell imports its PATH.
|
|
66
|
+
// Pin the Node runtime and persist the install-time PATH so the runner and
|
|
67
|
+
// children such as `ours-mcp proxy` resolve the same tools after reboot.
|
|
68
|
+
const servicePath = [...new Set([
|
|
69
|
+
dirname(process.execPath),
|
|
70
|
+
...(process.env.PATH ?? '').split(delimiter),
|
|
71
|
+
].filter(Boolean))].join(delimiter);
|
|
63
72
|
mkdirSync(unitDir, { recursive: true });
|
|
64
73
|
writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
|
|
65
74
|
Description=ours-fleet agent %i
|
|
@@ -67,7 +76,8 @@ After=default.target
|
|
|
67
76
|
|
|
68
77
|
[Service]
|
|
69
78
|
Type=simple
|
|
70
|
-
|
|
79
|
+
Environment="PATH=${servicePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"
|
|
80
|
+
ExecStart=${unitArg(process.execPath)} ${unitArg(binPath)} _run %i
|
|
71
81
|
# The RUNNER owns the child-session restart loop, with a counted, backed-off
|
|
72
82
|
# circuit breaker (3.2). systemd must only recover the runner PROCESS crashing —
|
|
73
83
|
# Restart=always here would resume the uncounted two-second relaunch loop, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|