@ours.network/fleet 0.12.0 → 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 +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/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 +86 -7
- package/dist/session/acp.d.ts +3 -2
- package/dist/session/acp.js +67 -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/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
|
|
@@ -437,7 +445,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
437
445
|
log: deps.log,
|
|
438
446
|
});
|
|
439
447
|
pid = acpSession.pid;
|
|
440
|
-
|
|
448
|
+
arbiter = new RoleTurnArbiter(acpSession);
|
|
449
|
+
sessionHandle = arbiter;
|
|
441
450
|
unsubscribeRecovery = acpSession.subscribe(event => {
|
|
442
451
|
if (event.kind !== 'error' || !event.text)
|
|
443
452
|
return;
|
|
@@ -445,7 +454,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
445
454
|
if (evidence)
|
|
446
455
|
resolvedMonitorDeps.onFailureEvidence?.(evidence);
|
|
447
456
|
});
|
|
448
|
-
control = new RoleControlServer(dir,
|
|
457
|
+
control = new RoleControlServer(dir, arbiter, deps.log);
|
|
449
458
|
await control.start();
|
|
450
459
|
resolvedMonitorDeps.delivery = {
|
|
451
460
|
// A wake is only delivered when its turn TERMINATES successfully. A
|
|
@@ -457,7 +466,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
457
466
|
// steer into the live turn instead; after it completes, honor the
|
|
458
467
|
// configured interrupt policy normally.
|
|
459
468
|
const interrupt = options?.interrupt === true && acpStartupComplete;
|
|
460
|
-
const result = await
|
|
469
|
+
const result = await arbiter.submitPrompt(text, {
|
|
470
|
+
...options, interrupt, steer: true,
|
|
471
|
+
...(interrupt ? { interruptSource: 'fleet-monitor' } : {}),
|
|
472
|
+
origin: { kind: 'fleet-monitor' },
|
|
473
|
+
});
|
|
461
474
|
const steered = result.accepted
|
|
462
475
|
&& (result.detail === 'injected' || result.detail === 'startedNewTurn');
|
|
463
476
|
return {
|
|
@@ -473,7 +486,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
473
486
|
// Wait for the first turn's TERMINAL result. An agent that accepts the
|
|
474
487
|
// startup prompt and then refuses it has not started; logging the role as
|
|
475
488
|
// up would hide a role that never read its briefing.
|
|
476
|
-
const starting =
|
|
489
|
+
const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
|
|
477
490
|
// Monitoring starts immediately. The delivery adapter above downgrades
|
|
478
491
|
// interruption to steering until this startup turn reaches a terminal
|
|
479
492
|
// success, so there is neither a deaf gap nor a boot-cancellation loop.
|
|
@@ -507,7 +520,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
507
520
|
ownerChannel = deps.createOwnerChannel({
|
|
508
521
|
role: name,
|
|
509
522
|
config: role.owner_channel,
|
|
510
|
-
session:
|
|
523
|
+
session: arbiter,
|
|
511
524
|
stateDir: dir,
|
|
512
525
|
env: role.env,
|
|
513
526
|
log: deps.log,
|
|
@@ -525,6 +538,48 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
525
538
|
throw new Error(`[${name}] owner channel failed to start: `
|
|
526
539
|
+ `${error?.message ?? String(error)}`);
|
|
527
540
|
}
|
|
541
|
+
control.setOwnerChannel(ownerChannel);
|
|
542
|
+
}
|
|
543
|
+
reloadLoopConfig = async () => {
|
|
544
|
+
const nextRole = findRole(loadConfig(configPath), name);
|
|
545
|
+
const definitions = nextRole.loops ?? [];
|
|
546
|
+
const generation = JSON.stringify(definitions.map(loop => [
|
|
547
|
+
loop.name, loop.definitionHash, loop.promptHash,
|
|
548
|
+
]));
|
|
549
|
+
if (generation === loopGeneration && (loopManager || !definitions.length))
|
|
550
|
+
return { changed: false, loops: definitions.length };
|
|
551
|
+
if (!loopManager && definitions.length) {
|
|
552
|
+
loopManager = new ScheduledLoopManager(name, definitions, dir, arbiter, {
|
|
553
|
+
now: deps.now,
|
|
554
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
555
|
+
clearTimer: timer => clearTimeout(timer),
|
|
556
|
+
log: deps.log,
|
|
557
|
+
});
|
|
558
|
+
control.setLoopManager(loopManager);
|
|
559
|
+
loopManager.start();
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
loopManager?.reconcile(definitions);
|
|
563
|
+
}
|
|
564
|
+
loopGeneration = generation;
|
|
565
|
+
deps.log(`[${name}] scheduled loops reloaded (${definitions.length} definitions)`);
|
|
566
|
+
return { changed: true, loops: definitions.length };
|
|
567
|
+
};
|
|
568
|
+
control.setConfigReloader(reloadLoopConfig);
|
|
569
|
+
if (role.loops?.length) {
|
|
570
|
+
try {
|
|
571
|
+
loopManager = new ScheduledLoopManager(name, role.loops, dir, arbiter, {
|
|
572
|
+
now: deps.now,
|
|
573
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
574
|
+
clearTimer: timer => clearTimeout(timer),
|
|
575
|
+
log: deps.log,
|
|
576
|
+
});
|
|
577
|
+
control.setLoopManager(loopManager);
|
|
578
|
+
loopManager.start();
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
deps.log(`[${name}] scheduled loop manager unavailable: ${error?.name ?? 'Error'}`);
|
|
582
|
+
}
|
|
528
583
|
}
|
|
529
584
|
}
|
|
530
585
|
else {
|
|
@@ -546,10 +601,34 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
546
601
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
547
602
|
monitorLoop ??= monitor?.run(pid);
|
|
548
603
|
const start = deps.now();
|
|
549
|
-
|
|
604
|
+
let nextLoopReloadAt = deps.now() + 30_000;
|
|
605
|
+
let lastReloadError = '';
|
|
606
|
+
while (sessionHandle.isAlive()) {
|
|
550
607
|
await deps.sleep(2000);
|
|
551
|
-
|
|
608
|
+
const now = deps.now();
|
|
609
|
+
if (reloadLoopConfig && now >= nextLoopReloadAt) {
|
|
610
|
+
nextLoopReloadAt = now + 30_000;
|
|
611
|
+
try {
|
|
612
|
+
await reloadLoopConfig();
|
|
613
|
+
lastReloadError = '';
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
const message = error?.message ?? String(error);
|
|
617
|
+
if (message !== lastReloadError)
|
|
618
|
+
deps.log(`[${name}] scheduled loop config reload rejected: ${message}`);
|
|
619
|
+
lastReloadError = message;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (loopManager) {
|
|
624
|
+
control?.setLoopManager(undefined);
|
|
625
|
+
await loopManager.stop();
|
|
626
|
+
}
|
|
627
|
+
control?.setConfigReloader(undefined);
|
|
628
|
+
if (ownerChannel) {
|
|
629
|
+
control?.setOwnerChannel(undefined);
|
|
552
630
|
await ownerChannel.close();
|
|
631
|
+
}
|
|
553
632
|
if (monitor) {
|
|
554
633
|
monitor.stop();
|
|
555
634
|
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[];
|
|
@@ -50,7 +50,8 @@ export declare class AcpSession implements SessionHandle {
|
|
|
50
50
|
*/
|
|
51
51
|
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
52
52
|
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
53
|
-
interrupt(): Promise<void>;
|
|
53
|
+
interrupt(source?: TurnCancellationSource): Promise<void>;
|
|
54
|
+
private cancelActive;
|
|
54
55
|
respondPermission(permissionId: string, optionId: string): boolean;
|
|
55
56
|
eventsSince(seq: number): SessionEvent[];
|
|
56
57
|
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',
|
|
@@ -239,28 +254,36 @@ export class AcpSession {
|
|
|
239
254
|
this.readiness = 'idle';
|
|
240
255
|
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
241
256
|
}
|
|
242
|
-
async runPrompt(text, turnId = randomUUID()) {
|
|
257
|
+
async runPrompt(text, turnId = randomUUID(), origin) {
|
|
243
258
|
if (!this.sessionId || !this.isAlive())
|
|
244
259
|
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
245
260
|
this.readiness = 'running';
|
|
246
|
-
this.activeTurn = { id: turnId, output: '' };
|
|
247
|
-
this.events.emit('state', { turnId, status: 'running' });
|
|
261
|
+
this.activeTurn = { id: turnId, output: '', origin };
|
|
262
|
+
this.events.emit('state', { turnId, status: 'running', origin });
|
|
248
263
|
try {
|
|
249
264
|
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
250
265
|
sessionId: this.sessionId,
|
|
251
266
|
prompt: [{ type: 'text', text }],
|
|
252
267
|
});
|
|
253
268
|
this.readiness = 'idle';
|
|
254
|
-
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
|
+
});
|
|
255
274
|
this.events.emit('state', { status: 'idle' });
|
|
256
275
|
// The prompt was accepted either way — the agent answered. Whether the
|
|
257
276
|
// 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);
|
|
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);
|
|
259
278
|
}
|
|
260
279
|
catch (error) {
|
|
261
|
-
|
|
280
|
+
const detail = error?.message ?? String(error);
|
|
281
|
+
this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
|
|
262
282
|
this.readiness = this.isAlive() ? 'idle' : 'failed';
|
|
263
|
-
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
|
+
});
|
|
264
287
|
if (this.isAlive())
|
|
265
288
|
this.events.emit('state', { status: 'idle' });
|
|
266
289
|
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
@@ -320,12 +343,17 @@ export class AcpSession {
|
|
|
320
343
|
this.readiness = 'awaiting_permission';
|
|
321
344
|
this.events.emit('permission', {
|
|
322
345
|
turnId: this.activeTurn?.id,
|
|
346
|
+
origin: this.activeTurn?.origin,
|
|
323
347
|
permissionId,
|
|
324
|
-
toolCallId:
|
|
325
|
-
|
|
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',
|
|
326
352
|
status: 'pending',
|
|
327
353
|
options: params.options.map(option => ({
|
|
328
|
-
optionId: option.optionId,
|
|
354
|
+
optionId: option.optionId,
|
|
355
|
+
name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
|
|
356
|
+
kind: option.kind,
|
|
329
357
|
})),
|
|
330
358
|
});
|
|
331
359
|
return new Promise(resolve => {
|
|
@@ -341,16 +369,23 @@ export class AcpSession {
|
|
|
341
369
|
const settled = option ? decision : 'cancelled';
|
|
342
370
|
this.events.emit('permission', {
|
|
343
371
|
turnId: this.activeTurn?.id,
|
|
372
|
+
origin: this.activeTurn?.origin,
|
|
344
373
|
permissionId: randomUUID(),
|
|
345
|
-
toolCallId:
|
|
346
|
-
|
|
374
|
+
toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
|
|
375
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
347
376
|
status: 'completed',
|
|
348
377
|
decision: settled,
|
|
349
378
|
decisionSource: 'automatic',
|
|
350
379
|
policy,
|
|
351
380
|
reason: option ? reason : `${reason}, but the agent offered no matching option`,
|
|
352
381
|
optionId: option?.optionId,
|
|
353
|
-
|
|
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
|
+
})),
|
|
354
389
|
});
|
|
355
390
|
return option
|
|
356
391
|
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
@@ -373,34 +408,41 @@ export class AcpSession {
|
|
|
373
408
|
});
|
|
374
409
|
}
|
|
375
410
|
recordUpdate(update) {
|
|
411
|
+
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
376
412
|
switch (update.sessionUpdate) {
|
|
377
413
|
case 'agent_message_chunk':
|
|
378
414
|
if (this.activeTurn && update.content.type === 'text')
|
|
379
415
|
this.activeTurn.output += update.content.text;
|
|
380
416
|
this.events.emit('agent_text', {
|
|
381
417
|
turnId: this.activeTurn?.id,
|
|
382
|
-
|
|
418
|
+
origin: this.activeTurn?.origin,
|
|
419
|
+
text: scheduled ? '[scheduled-loop output redacted]'
|
|
420
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
383
421
|
});
|
|
384
422
|
break;
|
|
385
423
|
case 'agent_thought_chunk':
|
|
386
424
|
this.events.emit('thought', {
|
|
387
425
|
turnId: this.activeTurn?.id,
|
|
388
|
-
|
|
426
|
+
origin: this.activeTurn?.origin,
|
|
427
|
+
text: scheduled ? '[scheduled-loop thought redacted]'
|
|
428
|
+
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
389
429
|
});
|
|
390
430
|
break;
|
|
391
431
|
case 'tool_call':
|
|
392
432
|
this.events.emit('tool_call', {
|
|
393
433
|
turnId: this.activeTurn?.id,
|
|
394
|
-
|
|
395
|
-
|
|
434
|
+
origin: this.activeTurn?.origin,
|
|
435
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
436
|
+
title: scheduled ? 'scheduled-loop tool' : update.title,
|
|
396
437
|
status: update.status,
|
|
397
438
|
});
|
|
398
439
|
break;
|
|
399
440
|
case 'tool_call_update':
|
|
400
441
|
this.events.emit('tool_update', {
|
|
401
442
|
turnId: this.activeTurn?.id,
|
|
402
|
-
|
|
403
|
-
|
|
443
|
+
origin: this.activeTurn?.origin,
|
|
444
|
+
toolCallId: scheduled ? 'scheduled-loop-tool' : update.toolCallId,
|
|
445
|
+
title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
|
|
404
446
|
status: update.status ?? undefined,
|
|
405
447
|
});
|
|
406
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
|
}
|