@ours.network/fleet 0.12.0 → 0.13.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/README.md +184 -0
- package/dist/briefing.js +10 -0
- package/dist/cli.js +404 -1
- package/dist/config.d.ts +18 -2
- package/dist/config.js +67 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +111 -0
- package/dist/duration.js +7 -3
- package/dist/harness/claude-code.js +5 -0
- package/dist/harness/types.d.ts +6 -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 +114 -2
- package/dist/owner-channel/channel.js +622 -43
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +34 -0
- package/dist/owner-channel/state.js +148 -1
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +11 -0
- package/dist/runner.js +87 -7
- package/dist/session/acp.d.ts +5 -2
- package/dist/session/acp.js +83 -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 +26 -2
- package/dist/session/types.js +5 -2
- package/dist/spawn.js +1 -0
- package/dist/supervisor/systemd.js +12 -2
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -19,6 +19,8 @@ import { classifyShellStatus } from './session/types.js';
|
|
|
19
19
|
import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
|
|
20
20
|
import { rotateWorklog } from './worklog.js';
|
|
21
21
|
import { OwnerChannel } from './owner-channel/channel.js';
|
|
22
|
+
import { RoleTurnArbiter } from './session/arbiter.js';
|
|
23
|
+
import { ScheduledLoopManager, } from './loops/manager.js';
|
|
22
24
|
const defaultDeps = () => ({
|
|
23
25
|
tmux: new Tmux(),
|
|
24
26
|
exec: realExec,
|
|
@@ -418,6 +420,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
418
420
|
let monitorLoop;
|
|
419
421
|
let acpStartupComplete = false;
|
|
420
422
|
let ownerChannel;
|
|
423
|
+
let loopManager;
|
|
424
|
+
let arbiter;
|
|
425
|
+
let reloadLoopConfig;
|
|
426
|
+
let loopGeneration = JSON.stringify((role.loops ?? []).map(loop => [
|
|
427
|
+
loop.name, loop.definitionHash, loop.promptHash,
|
|
428
|
+
]));
|
|
421
429
|
if (sessionBackend === 'acp') {
|
|
422
430
|
const perms = role.permissions ?? resolvePermissions(undefined, undefined);
|
|
423
431
|
// Say once, at startup, that this role will decide permission requests by
|
|
@@ -434,10 +442,12 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
434
442
|
stateDir: dir,
|
|
435
443
|
mode,
|
|
436
444
|
permissions: perms,
|
|
445
|
+
modeId: adapter.acpPermissionModeId?.(role),
|
|
437
446
|
log: deps.log,
|
|
438
447
|
});
|
|
439
448
|
pid = acpSession.pid;
|
|
440
|
-
|
|
449
|
+
arbiter = new RoleTurnArbiter(acpSession);
|
|
450
|
+
sessionHandle = arbiter;
|
|
441
451
|
unsubscribeRecovery = acpSession.subscribe(event => {
|
|
442
452
|
if (event.kind !== 'error' || !event.text)
|
|
443
453
|
return;
|
|
@@ -445,7 +455,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
445
455
|
if (evidence)
|
|
446
456
|
resolvedMonitorDeps.onFailureEvidence?.(evidence);
|
|
447
457
|
});
|
|
448
|
-
control = new RoleControlServer(dir,
|
|
458
|
+
control = new RoleControlServer(dir, arbiter, deps.log);
|
|
449
459
|
await control.start();
|
|
450
460
|
resolvedMonitorDeps.delivery = {
|
|
451
461
|
// A wake is only delivered when its turn TERMINATES successfully. A
|
|
@@ -457,7 +467,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
457
467
|
// steer into the live turn instead; after it completes, honor the
|
|
458
468
|
// configured interrupt policy normally.
|
|
459
469
|
const interrupt = options?.interrupt === true && acpStartupComplete;
|
|
460
|
-
const result = await
|
|
470
|
+
const result = await arbiter.submitPrompt(text, {
|
|
471
|
+
...options, interrupt, steer: true,
|
|
472
|
+
...(interrupt ? { interruptSource: 'fleet-monitor' } : {}),
|
|
473
|
+
origin: { kind: 'fleet-monitor' },
|
|
474
|
+
});
|
|
461
475
|
const steered = result.accepted
|
|
462
476
|
&& (result.detail === 'injected' || result.detail === 'startedNewTurn');
|
|
463
477
|
return {
|
|
@@ -473,7 +487,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
473
487
|
// Wait for the first turn's TERMINAL result. An agent that accepts the
|
|
474
488
|
// startup prompt and then refuses it has not started; logging the role as
|
|
475
489
|
// up would hide a role that never read its briefing.
|
|
476
|
-
const starting =
|
|
490
|
+
const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
|
|
477
491
|
// Monitoring starts immediately. The delivery adapter above downgrades
|
|
478
492
|
// interruption to steering until this startup turn reaches a terminal
|
|
479
493
|
// success, so there is neither a deaf gap nor a boot-cancellation loop.
|
|
@@ -507,7 +521,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
507
521
|
ownerChannel = deps.createOwnerChannel({
|
|
508
522
|
role: name,
|
|
509
523
|
config: role.owner_channel,
|
|
510
|
-
session:
|
|
524
|
+
session: arbiter,
|
|
511
525
|
stateDir: dir,
|
|
512
526
|
env: role.env,
|
|
513
527
|
log: deps.log,
|
|
@@ -525,6 +539,48 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
525
539
|
throw new Error(`[${name}] owner channel failed to start: `
|
|
526
540
|
+ `${error?.message ?? String(error)}`);
|
|
527
541
|
}
|
|
542
|
+
control.setOwnerChannel(ownerChannel);
|
|
543
|
+
}
|
|
544
|
+
reloadLoopConfig = async () => {
|
|
545
|
+
const nextRole = findRole(loadConfig(configPath), name);
|
|
546
|
+
const definitions = nextRole.loops ?? [];
|
|
547
|
+
const generation = JSON.stringify(definitions.map(loop => [
|
|
548
|
+
loop.name, loop.definitionHash, loop.promptHash,
|
|
549
|
+
]));
|
|
550
|
+
if (generation === loopGeneration && (loopManager || !definitions.length))
|
|
551
|
+
return { changed: false, loops: definitions.length };
|
|
552
|
+
if (!loopManager && definitions.length) {
|
|
553
|
+
loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
|
|
554
|
+
now: deps.now,
|
|
555
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
556
|
+
clearTimer: timer => clearTimeout(timer),
|
|
557
|
+
log: deps.log,
|
|
558
|
+
});
|
|
559
|
+
control.setLoopManager(loopManager);
|
|
560
|
+
loopManager.start();
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
loopManager?.reconcile(definitions);
|
|
564
|
+
}
|
|
565
|
+
loopGeneration = generation;
|
|
566
|
+
deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
|
|
567
|
+
return { changed: true, loops: definitions.length };
|
|
568
|
+
};
|
|
569
|
+
control.setConfigReloader(reloadLoopConfig);
|
|
570
|
+
if (role.loops?.length) {
|
|
571
|
+
try {
|
|
572
|
+
loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
|
|
573
|
+
now: deps.now,
|
|
574
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
575
|
+
clearTimer: timer => clearTimeout(timer),
|
|
576
|
+
log: deps.log,
|
|
577
|
+
});
|
|
578
|
+
control.setLoopManager(loopManager);
|
|
579
|
+
loopManager.start();
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
|
|
583
|
+
}
|
|
528
584
|
}
|
|
529
585
|
}
|
|
530
586
|
else {
|
|
@@ -546,10 +602,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
546
602
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
547
603
|
monitorLoop ??= monitor?.run(pid);
|
|
548
604
|
const start = deps.now();
|
|
549
|
-
|
|
605
|
+
let nextLoopReloadAt = deps.now() + 30_000;
|
|
606
|
+
let lastReloadError = '';
|
|
607
|
+
while (sessionHandle.isAlive()) {
|
|
550
608
|
await deps.sleep(2000);
|
|
551
|
-
|
|
609
|
+
const now = deps.now();
|
|
610
|
+
if (reloadLoopConfig && now >= nextLoopReloadAt) {
|
|
611
|
+
nextLoopReloadAt = now + 30_000;
|
|
612
|
+
try {
|
|
613
|
+
await reloadLoopConfig();
|
|
614
|
+
lastReloadError = '';
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
const message = error?.message ?? String(error);
|
|
618
|
+
if (message !== lastReloadError)
|
|
619
|
+
deps.log(`[${name}] scheduled loop config reload rejected: ${message}`);
|
|
620
|
+
lastReloadError = message;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (loopManager) {
|
|
625
|
+
control?.setLoopManager(undefined);
|
|
626
|
+
await loopManager.stop();
|
|
627
|
+
}
|
|
628
|
+
control?.setConfigReloader(undefined);
|
|
629
|
+
if (ownerChannel) {
|
|
630
|
+
control?.setOwnerChannel(undefined);
|
|
552
631
|
await ownerChannel.close();
|
|
632
|
+
}
|
|
553
633
|
if (monitor) {
|
|
554
634
|
monitor.stop();
|
|
555
635
|
await monitorLoop;
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommonPermissions } from '../config.js';
|
|
2
|
-
import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnOutcome, TurnResult } from './types.js';
|
|
2
|
+
import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
|
|
3
3
|
export interface AcpSessionOptions {
|
|
4
4
|
name: string;
|
|
5
5
|
argv: string[];
|
|
@@ -8,6 +8,8 @@ export interface AcpSessionOptions {
|
|
|
8
8
|
stateDir: string;
|
|
9
9
|
mode: 'fresh' | 'resume';
|
|
10
10
|
permissions: CommonPermissions;
|
|
11
|
+
/** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
|
|
12
|
+
modeId?: string;
|
|
11
13
|
log(line: string): void;
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
@@ -50,7 +52,8 @@ export declare class AcpSession implements SessionHandle {
|
|
|
50
52
|
*/
|
|
51
53
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
52
54
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
53
|
-
interrupt(): Promise<void>;
|
|
55
|
+
interrupt(source?: TurnCancellationSource): Promise<void>;
|
|
56
|
+
private cancelActive;
|
|
54
57
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
55
58
|
eventsSince(seq: number): SessionEvent[];
|
|
56
59
|
subscribe(listener: (event: SessionEvent) => void): () => void;
|
package/dist/session/acp.js
CHANGED
|
@@ -118,24 +118,24 @@ export class AcpSession {
|
|
|
118
118
|
if (!this.sessionId || !this.isAlive())
|
|
119
119
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
120
120
|
if (options.interrupt)
|
|
121
|
-
await this.
|
|
121
|
+
await this.cancelActive(options.interruptSource ?? 'local-console');
|
|
122
122
|
// Interrupting delivery must still use steering when supported. With no
|
|
123
123
|
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
124
124
|
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
125
125
|
// the entire wake-triggered turn terminated.
|
|
126
126
|
if (options.steer && this.steeringSupported) {
|
|
127
127
|
const promptId = randomUUID();
|
|
128
|
-
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text) };
|
|
128
|
+
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
|
|
129
129
|
}
|
|
130
130
|
const promptId = randomUUID();
|
|
131
131
|
const queuedBehind = this.queueDepth++;
|
|
132
|
-
const run = this.promptTail.then(() => this.runPrompt(text, promptId));
|
|
132
|
+
const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
|
|
133
133
|
this.promptTail = run.then(() => undefined, () => undefined);
|
|
134
134
|
const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
|
|
135
135
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
136
136
|
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
137
137
|
});
|
|
138
|
-
return { promptId, queuedBehind, completion };
|
|
138
|
+
return { promptId, queuedBehind, completion, origin: options.origin };
|
|
139
139
|
}
|
|
140
140
|
async submitPrompt(text, options = {}) {
|
|
141
141
|
try {
|
|
@@ -147,10 +147,24 @@ export class AcpSession {
|
|
|
147
147
|
throw error;
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
-
async interrupt() {
|
|
150
|
+
async interrupt(source = 'local-console') {
|
|
151
|
+
await this.cancelActive(source);
|
|
152
|
+
}
|
|
153
|
+
async cancelActive(source) {
|
|
151
154
|
if (!this.sessionId)
|
|
152
155
|
return;
|
|
153
|
-
|
|
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
|
+
}
|
|
154
168
|
for (const pending of this.pendingPermissions.values())
|
|
155
169
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
156
170
|
this.pendingPermissions.clear();
|
|
@@ -164,6 +178,7 @@ export class AcpSession {
|
|
|
164
178
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
165
179
|
this.events.emit('permission', {
|
|
166
180
|
turnId: this.activeTurn?.id,
|
|
181
|
+
origin: this.activeTurn?.origin,
|
|
167
182
|
permissionId,
|
|
168
183
|
status: 'completed',
|
|
169
184
|
decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
|
|
@@ -236,31 +251,55 @@ export class AcpSession {
|
|
|
236
251
|
this.sessionId = created.sessionId;
|
|
237
252
|
}
|
|
238
253
|
writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
|
|
254
|
+
// Deliver the configured permission mode whichever way the session came up
|
|
255
|
+
// (new, resume or load) — the launch flag never reaches an ACP agent. A
|
|
256
|
+
// refusal is loud but never fatal: the session then simply runs at the
|
|
257
|
+
// agent's own default.
|
|
258
|
+
if (this.options.modeId) {
|
|
259
|
+
try {
|
|
260
|
+
await this.connection.agent.request(acp.methods.agent.session.setMode, {
|
|
261
|
+
sessionId: this.sessionId,
|
|
262
|
+
modeId: this.options.modeId,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
this.options.log(`[${this.options.name}] acp: session/set_mode "${this.options.modeId}" failed ` +
|
|
267
|
+
`(${e instanceof Error ? e.message : String(e)}) — session runs at the agent default permission mode`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
239
270
|
this.readiness = 'idle';
|
|
240
271
|
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
241
272
|
}
|
|
242
|
-
async runPrompt(text, turnId = randomUUID()) {
|
|
273
|
+
async runPrompt(text, turnId = randomUUID(), origin) {
|
|
243
274
|
if (!this.sessionId || !this.isAlive())
|
|
244
275
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
245
276
|
this.readiness = 'running';
|
|
246
|
-
this.activeTurn = { id: turnId, output: '' };
|
|
247
|
-
this.events.emit('state', { turnId, status: 'running' });
|
|
277
|
+
this.activeTurn = { id: turnId, output: '', origin };
|
|
278
|
+
this.events.emit('state', { turnId, status: 'running', origin });
|
|
248
279
|
try {
|
|
249
280
|
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
250
281
|
sessionId: this.sessionId,
|
|
251
282
|
prompt: [{ type: 'text', text }],
|
|
252
283
|
});
|
|
253
284
|
this.readiness = 'idle';
|
|
254
|
-
this.events.emit('turn_stop', {
|
|
285
|
+
this.events.emit('turn_stop', {
|
|
286
|
+
turnId, stopReason: response.stopReason, origin,
|
|
287
|
+
cancellationSource: this.activeTurn?.id === turnId
|
|
288
|
+
? this.activeTurn.cancellationSource : undefined,
|
|
289
|
+
});
|
|
255
290
|
this.events.emit('state', { status: 'idle' });
|
|
256
291
|
// The prompt was accepted either way — the agent answered. Whether the
|
|
257
292
|
// turn SUCCEEDED is a separate question, and only `stopReason` answers it.
|
|
258
|
-
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
293
|
+
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
|
|
259
294
|
}
|
|
260
295
|
catch (error) {
|
|
261
|
-
|
|
296
|
+
const detail = error?.message ?? String(error);
|
|
297
|
+
this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
|
|
262
298
|
this.readiness = this.isAlive() ? 'idle' : 'failed';
|
|
263
|
-
this.events.emit('error', {
|
|
299
|
+
this.events.emit('error', {
|
|
300
|
+
turnId, origin,
|
|
301
|
+
text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
|
|
302
|
+
});
|
|
264
303
|
if (this.isAlive())
|
|
265
304
|
this.events.emit('state', { status: 'idle' });
|
|
266
305
|
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
@@ -320,12 +359,17 @@ export class AcpSession {
|
|
|
320
359
|
this.readiness = 'awaiting_permission';
|
|
321
360
|
this.events.emit('permission', {
|
|
322
361
|
turnId: this.activeTurn?.id,
|
|
362
|
+
origin: this.activeTurn?.origin,
|
|
323
363
|
permissionId,
|
|
324
|
-
toolCallId:
|
|
325
|
-
|
|
364
|
+
toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
365
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
366
|
+
title: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
367
|
+
? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
|
|
326
368
|
status: 'pending',
|
|
327
369
|
options: params.options.map(option => ({
|
|
328
|
-
optionId: option.optionId,
|
|
370
|
+
optionId: option.optionId,
|
|
371
|
+
name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
|
|
372
|
+
kind: option.kind,
|
|
329
373
|
})),
|
|
330
374
|
});
|
|
331
375
|
return new Promise(resolve => {
|
|
@@ -341,16 +385,23 @@ export class AcpSession {
|
|
|
341
385
|
const settled = option ? decision : 'cancelled';
|
|
342
386
|
this.events.emit('permission', {
|
|
343
387
|
turnId: this.activeTurn?.id,
|
|
388
|
+
origin: this.activeTurn?.origin,
|
|
344
389
|
permissionId: randomUUID(),
|
|
345
|
-
toolCallId:
|
|
346
|
-
|
|
390
|
+
toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
391
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
347
392
|
status: 'completed',
|
|
348
393
|
decision: settled,
|
|
349
394
|
decisionSource: 'automatic',
|
|
350
395
|
policy,
|
|
351
396
|
reason: option ? reason : `${reason}, but the agent offered no matching option`,
|
|
352
397
|
optionId: option?.optionId,
|
|
353
|
-
|
|
398
|
+
title: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
399
|
+
? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
|
|
400
|
+
options: params.options.map(o => ({
|
|
401
|
+
optionId: o.optionId,
|
|
402
|
+
name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? o.kind : o.name,
|
|
403
|
+
kind: o.kind,
|
|
404
|
+
})),
|
|
354
405
|
});
|
|
355
406
|
return option
|
|
356
407
|
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
@@ -373,34 +424,41 @@ export class AcpSession {
|
|
|
373
424
|
});
|
|
374
425
|
}
|
|
375
426
|
recordUpdate(update) {
|
|
427
|
+
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
376
428
|
switch (update.sessionUpdate) {
|
|
377
429
|
case 'agent_message_chunk':
|
|
378
430
|
if (this.activeTurn && update.content.type === 'text')
|
|
379
431
|
this.activeTurn.output += update.content.text;
|
|
380
432
|
this.events.emit('agent_text', {
|
|
381
433
|
turnId: this.activeTurn?.id,
|
|
382
|
-
|
|
434
|
+
origin: this.activeTurn?.origin,
|
|
435
|
+
text: scheduled ? '[scheduled-loop output redacted]'
|
|
436
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
383
437
|
});
|
|
384
438
|
break;
|
|
385
439
|
case 'agent_thought_chunk':
|
|
386
440
|
this.events.emit('thought', {
|
|
387
441
|
turnId: this.activeTurn?.id,
|
|
388
|
-
|
|
442
|
+
origin: this.activeTurn?.origin,
|
|
443
|
+
text: scheduled ? '[scheduled-loop thought redacted]'
|
|
444
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
389
445
|
});
|
|
390
446
|
break;
|
|
391
447
|
case 'tool_call':
|
|
392
448
|
this.events.emit('tool_call', {
|
|
393
449
|
turnId: this.activeTurn?.id,
|
|
394
|
-
|
|
395
|
-
|
|
450
|
+
origin: this.activeTurn?.origin,
|
|
451
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
452
|
+
title: scheduled ? 'scheduled-loop tool' : update.title,
|
|
396
453
|
status: update.status,
|
|
397
454
|
});
|
|
398
455
|
break;
|
|
399
456
|
case 'tool_call_update':
|
|
400
457
|
this.events.emit('tool_update', {
|
|
401
458
|
turnId: this.activeTurn?.id,
|
|
402
|
-
|
|
403
|
-
|
|
459
|
+
origin: this.activeTurn?.origin,
|
|
460
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
461
|
+
title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
|
|
404
462
|
status: update.status ?? undefined,
|
|
405
463
|
});
|
|
406
464
|
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
|
}
|