@ours.network/fleet 0.17.5 → 0.17.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +5 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.js +20 -4
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +65 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/session/acp.d.ts +44 -0
- package/dist/session/acp.js +92 -1
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/types.d.ts +24 -0
- package/dist/watchdog/briefing.js +7 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstatSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { classifyActivity } from '../session/activity.js';
|
|
3
4
|
import { controlRequest } from '../session/control.js';
|
|
4
5
|
import { SessionControlError } from '../session/types.js';
|
|
5
6
|
import { readExitRecord, readRestartLedger } from '../runner.js';
|
|
@@ -55,6 +56,10 @@ function sessionOverall(supervisor, session, restart, monitor, isolation, proble
|
|
|
55
56
|
if (session.reachability === 'online'
|
|
56
57
|
&& (session.readiness === 'running' || session.readiness === 'awaiting_permission'))
|
|
57
58
|
return 'busy';
|
|
59
|
+
// `readiness: idle` alone never justifies `ready`. A steered wake turn is
|
|
60
|
+
// invisible to readiness (FLEET-002), so observed activity outranks it.
|
|
61
|
+
if (session.reachability === 'online' && session.activity.state === 'active')
|
|
62
|
+
return 'busy';
|
|
58
63
|
if (session.reachability === 'online' && session.readiness === 'idle')
|
|
59
64
|
return 'ready';
|
|
60
65
|
if (session.reachability === 'offline'
|
|
@@ -153,6 +158,7 @@ export class FleetQueryService {
|
|
|
153
158
|
protocolVersion: snapshot.protocolVersion, features: snapshot.features,
|
|
154
159
|
runtimeModel: snapshot.runtimeModel, reasoningEffort: snapshot.reasoningEffort,
|
|
155
160
|
permissionMode: snapshot.permissionMode,
|
|
161
|
+
activity: classifyActivity(snapshot.activity),
|
|
156
162
|
};
|
|
157
163
|
}
|
|
158
164
|
catch (error) {
|
|
@@ -164,6 +170,7 @@ export class FleetQueryService {
|
|
|
164
170
|
: failure === 'control-unavailable' ? 'unavailable' : 'unknown',
|
|
165
171
|
readiness: offline ? 'failed' : 'unknown',
|
|
166
172
|
evidence: 'authoritative', lastError: clean(error.message),
|
|
173
|
+
activity: { state: 'unobservable' },
|
|
167
174
|
};
|
|
168
175
|
}
|
|
169
176
|
}
|
|
@@ -174,18 +181,23 @@ export class FleetQueryService {
|
|
|
174
181
|
backend: 'tmux', reachability: has ? 'online' : supervisor === 'stopped' ? 'offline' : 'unknown',
|
|
175
182
|
readiness: has ? 'idle' : supervisor === 'running' ? 'starting' : 'failed',
|
|
176
183
|
evidence: 'inferred',
|
|
184
|
+
// tmux exposes no agent-side evidence at all, and `readiness: idle`
|
|
185
|
+
// here is a pane-liveness inference, not an activity claim.
|
|
186
|
+
activity: { state: 'unobservable' },
|
|
177
187
|
};
|
|
178
188
|
}
|
|
179
189
|
catch (error) {
|
|
180
190
|
return {
|
|
181
191
|
backend: 'tmux', reachability: 'unknown', readiness: 'unknown',
|
|
182
192
|
evidence: 'inferred', lastError: clean(error.message),
|
|
193
|
+
activity: { state: 'unobservable' },
|
|
183
194
|
};
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
return {
|
|
187
198
|
backend: 'unknown', reachability: supervisor === 'stopped' ? 'offline' : 'unknown',
|
|
188
199
|
readiness: supervisor === 'stopped' ? 'failed' : 'unknown', evidence: 'inferred',
|
|
200
|
+
activity: { state: 'unobservable' },
|
|
189
201
|
};
|
|
190
202
|
}
|
|
191
203
|
}
|
|
@@ -72,6 +72,17 @@ export interface RoleStatus {
|
|
|
72
72
|
runtimeModel?: SessionSnapshot['runtimeModel'];
|
|
73
73
|
reasoningEffort?: SessionSnapshot['reasoningEffort'];
|
|
74
74
|
permissionMode?: SessionSnapshot['permissionMode'];
|
|
75
|
+
/**
|
|
76
|
+
* Activity evidence, kept separate from `readiness` on purpose: `readiness`
|
|
77
|
+
* answers "is a fleet-tracked turn in flight" (the prompt-admission gate),
|
|
78
|
+
* NOT "is this agent working". `state` is the only field a human-facing
|
|
79
|
+
* surface may use to call a role idle or stalled.
|
|
80
|
+
*/
|
|
81
|
+
activity: {
|
|
82
|
+
state: 'active' | 'quiet' | 'unobservable';
|
|
83
|
+
activeToolCalls?: number;
|
|
84
|
+
lastUpdateAt?: string;
|
|
85
|
+
};
|
|
75
86
|
};
|
|
76
87
|
restart: {
|
|
77
88
|
circuit: 'closed' | 'open';
|
package/dist/briefing.js
CHANGED
|
@@ -168,6 +168,11 @@ export function generateBriefing(role, v, opts) {
|
|
|
168
168
|
L.push('Never translate any other failure into "dead". A busy agent, an unanswered control');
|
|
169
169
|
L.push('plane and a confirmed stop look identical if you only look at one command.');
|
|
170
170
|
L.push('');
|
|
171
|
+
L.push('`ours-fleet status` reports `session.readiness`, which is TURN OCCUPANCY only: a mail');
|
|
172
|
+
L.push('wake delivered by steering runs a whole turn with readiness pinned at `idle`. Read the');
|
|
173
|
+
L.push('`activity:` line beside it — `active` means the role is working — and never call a role');
|
|
174
|
+
L.push('idle or stalled from `readiness=idle` alone.');
|
|
175
|
+
L.push('');
|
|
171
176
|
L.push('Then judge the console content: stuck on a prompt/menu/trust dialog → answer it directly');
|
|
172
177
|
L.push('with `ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); idle with work');
|
|
173
178
|
L.push('assigned → nudge; actively working → do nothing, and do not mistake a long turn for a');
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.17.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "0.17.7",
|
|
3
|
+
"buildId": "8afdf08c9c69",
|
|
4
|
+
"commit": "1a6243fe213281c0b82f5bfeaf97dfc4c697fcf7",
|
|
5
5
|
"dirty": false,
|
|
6
|
-
"builtAt": "2026-08-
|
|
6
|
+
"builtAt": "2026-08-17T12:01:37.858Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"monitor.interrupt.after_tool"
|
|
9
9
|
]
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ import { creationBuildNote, formatProvenance, readProvenance } from './creation.
|
|
|
27
27
|
import { doctor } from './doctor.js';
|
|
28
28
|
import { allWarnings, analyzeFleetPermissions, effectivePermissionMode, formatNative, } from './permissions.js';
|
|
29
29
|
import { AI_DOCS } from './docs.js';
|
|
30
|
+
import { classifyActivity, describeSessionState } from './session/activity.js';
|
|
30
31
|
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
31
32
|
import { SessionControlError } from './session/types.js';
|
|
32
33
|
import { readScheduledLoops, storedLoopHealth } from './loops/state.js';
|
|
@@ -363,8 +364,14 @@ program.command('ls').description('list running fleet sessions')
|
|
|
363
364
|
continue;
|
|
364
365
|
try {
|
|
365
366
|
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
366
|
-
|
|
367
|
-
|
|
367
|
+
const result = response.result;
|
|
368
|
+
if (response.ok && result?.alive) {
|
|
369
|
+
// Activity, not readiness: an idle-readiness role may be running a
|
|
370
|
+
// steered wake turn (FLEET-002), so `ls` reports what was observed.
|
|
371
|
+
const observed = classifyActivity(result.activity);
|
|
372
|
+
acp.push(`${name}: acp${observed.state === 'active' ? ' (working)'
|
|
373
|
+
: observed.state === 'quiet' ? ' (no recent agent activity)' : ''}`);
|
|
374
|
+
}
|
|
368
375
|
}
|
|
369
376
|
catch { /* ignore stale sockets */ }
|
|
370
377
|
}
|
|
@@ -523,8 +530,14 @@ program.command('status <name>').description('unit/agent state')
|
|
|
523
530
|
if (stateDir) {
|
|
524
531
|
try {
|
|
525
532
|
const response = await controlRequest(stateDir, { command: 'status' }, 2_000);
|
|
526
|
-
if (response.ok)
|
|
533
|
+
if (response.ok) {
|
|
534
|
+
const snapshot = response.result;
|
|
527
535
|
console.log(`session: ${JSON.stringify(response.result)}`);
|
|
536
|
+
// `readiness` is turn occupancy, never an activity claim: a steered
|
|
537
|
+
// wake turn runs to completion with readiness pinned at `idle`
|
|
538
|
+
// (FLEET-002). Say which question each field answers.
|
|
539
|
+
console.log(describeSessionState(snapshot.readiness, snapshot.activity));
|
|
540
|
+
}
|
|
528
541
|
}
|
|
529
542
|
catch {
|
|
530
543
|
console.log('session: acp control unavailable');
|
|
@@ -635,7 +648,10 @@ function renderLoopRows(role, state, loop) {
|
|
|
635
648
|
return Object.entries(state.loops).filter(([name]) => !loop || name === loop).map(([name, item]) => `${role}/${name} ${item.enabled && !item.operatorDisabled ? 'enabled' : 'disabled'} `
|
|
636
649
|
+ `${item.activeRunId ? 'running' : 'idle'} next=${item.nextDueAt} last=${item.lastOutcome ?? 'never'} `
|
|
637
650
|
+ `counts=${item.counts.started}/${item.counts.completed}/${item.counts.failed} `
|
|
638
|
-
+ `skip=${item.counts.skipped}(busy=${item.counts.skippedBusy},missed=${item.counts.skippedMissed})`
|
|
651
|
+
+ `skip=${item.counts.skipped}(busy=${item.counts.skippedBusy},missed=${item.counts.skippedMissed})`
|
|
652
|
+
+ (item.missedGap
|
|
653
|
+
? ` gap=${item.missedGap.count}@${item.missedGap.fromAt}..${item.missedGap.throughAt}`
|
|
654
|
+
: ''));
|
|
639
655
|
}
|
|
640
656
|
cOpt(loopsCommand.command('status [role] [loop]').description('show live or stored loop state'))
|
|
641
657
|
.option('--json', 'emit stable JSON')
|
package/dist/loops/manager.d.ts
CHANGED
|
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
68
68
|
private armAbandon;
|
|
69
69
|
private finish;
|
|
70
70
|
private advance;
|
|
71
|
+
/**
|
|
72
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
73
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
74
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
75
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
76
|
+
* was reading the log at the time.
|
|
77
|
+
*/
|
|
71
78
|
private skipMissed;
|
|
72
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
81
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
82
|
+
* runs the tick late; this path used to drop anything already due however
|
|
83
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
84
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
85
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
86
|
+
*
|
|
87
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
88
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
89
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
90
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
91
|
+
*
|
|
92
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
93
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
94
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
95
|
+
*/
|
|
96
|
+
private skipRestartBacklog;
|
|
73
97
|
/**
|
|
74
98
|
* A run the store could not record is dropped, not retried: the cursor has
|
|
75
99
|
* already moved, so this can never become a busy loop, and the outage is
|
|
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
|
|
|
86
110
|
* until the process was restarted.
|
|
87
111
|
*/
|
|
88
112
|
private recover;
|
|
113
|
+
/**
|
|
114
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
115
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
116
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
117
|
+
*/
|
|
89
118
|
private envelope;
|
|
90
119
|
}
|
package/dist/loops/manager.js
CHANGED
|
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
|
|
|
39
39
|
}
|
|
40
40
|
start() {
|
|
41
41
|
if (!this.store.fresh)
|
|
42
|
-
this.
|
|
42
|
+
this.skipRestartBacklog();
|
|
43
43
|
this.schedule();
|
|
44
44
|
}
|
|
45
45
|
async stop() {
|
|
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
|
|
|
143
143
|
async attempt(definition, state, scheduledAt) {
|
|
144
144
|
const runId = `sl_${randomUUID()}`;
|
|
145
145
|
const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
|
|
146
|
-
|
|
146
|
+
// The gap is read here and cleared only if the turn is actually admitted:
|
|
147
|
+
// an attempt that ends `skipped_busy` or `unavailable` reported it to
|
|
148
|
+
// nobody, so it has to still be there for the attempt that succeeds.
|
|
149
|
+
const gap = state.missedGap;
|
|
150
|
+
const prompt = this.envelope(definition, runId, scheduledAt, gap);
|
|
147
151
|
let claimed = false;
|
|
148
152
|
const result = await this.arbiter.tryScheduled(prompt, origin, () => {
|
|
149
153
|
claimed = true;
|
|
154
|
+
state.missedGap = null;
|
|
150
155
|
state.activeRunId = runId;
|
|
151
156
|
state.lastRunId = runId;
|
|
152
157
|
state.lastStartedAt = new Date(this.deps.now()).toISOString();
|
|
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
|
|
|
265
270
|
state.nextScheduledAt = new Date(next).toISOString();
|
|
266
271
|
state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
|
|
267
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Coalesce a backlog into one skip. The counters alone say how many
|
|
275
|
+
* occurrences were lost but never when or for how long, so the window is
|
|
276
|
+
* recorded too and carried on the state until a run is actually told about it
|
|
277
|
+
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
278
|
+
* was reading the log at the time.
|
|
279
|
+
*/
|
|
268
280
|
skipMissed(definition, state, now) {
|
|
281
|
+
const from = state.nextScheduledAt;
|
|
269
282
|
let missed = 0;
|
|
270
283
|
while (Date.parse(state.nextDueAt) <= now) {
|
|
271
284
|
this.advance(definition, state);
|
|
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
|
|
|
275
288
|
state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
|
|
276
289
|
state.lastOutcome = 'skipped_missed';
|
|
277
290
|
state.lastFinishedAt = new Date(now).toISOString();
|
|
291
|
+
// Successive outages before any run lands merge into one gap: the earliest
|
|
292
|
+
// start wins, so the window always spans the whole silence.
|
|
293
|
+
const previous = state.missedGap;
|
|
294
|
+
state.missedGap = {
|
|
295
|
+
count: increment(previous?.count ?? 0, missed),
|
|
296
|
+
fromAt: previous?.fromAt ?? from,
|
|
297
|
+
throughAt: state.lastScheduledAt ?? from,
|
|
298
|
+
detectedAt: new Date(now).toISOString(),
|
|
299
|
+
};
|
|
278
300
|
this.store.persist();
|
|
279
|
-
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`
|
|
301
|
+
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
|
|
302
|
+
+ `gap=${from}..${state.missedGap.throughAt} `
|
|
303
|
+
+ `unreported=${state.missedGap.count}`);
|
|
280
304
|
}
|
|
281
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
307
|
+
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
308
|
+
* runs the tick late; this path used to drop anything already due however
|
|
309
|
+
* recently, so a role restarted seconds after its own tick came due lost it
|
|
310
|
+
* outright. For an oversight role that is precisely the pass which would have
|
|
311
|
+
* recorded why it restarted, so the failure erased its own witness.
|
|
312
|
+
*
|
|
313
|
+
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
314
|
+
* interval deep is still coalesced into a single skip and never replayed —
|
|
315
|
+
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
316
|
+
* arms it through the ordinary path rather than firing a burst here.
|
|
317
|
+
*
|
|
318
|
+
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
319
|
+
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
320
|
+
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
321
|
+
*/
|
|
322
|
+
skipRestartBacklog() {
|
|
282
323
|
const now = this.deps.now();
|
|
283
324
|
for (const definition of this.definitions.values()) {
|
|
284
325
|
const state = this.store.state.loops[definition.name];
|
|
285
|
-
if (definition.enabled && !state.operatorDisabled
|
|
326
|
+
if (definition.enabled && !state.operatorDisabled
|
|
327
|
+
&& now >= Date.parse(state.nextDueAt) + definition.intervalMs)
|
|
286
328
|
this.skipMissed(definition, state, now);
|
|
287
329
|
}
|
|
288
330
|
}
|
|
@@ -340,17 +382,34 @@ export class ScheduledLoopManager {
|
|
|
340
382
|
this.deps.clearTimer(this.timer);
|
|
341
383
|
this.arm(backoffMs(this.pollFailures));
|
|
342
384
|
}
|
|
343
|
-
|
|
385
|
+
/**
|
|
386
|
+
* The envelope is the only channel a scheduled pass has for learning about
|
|
387
|
+
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
388
|
+
* role report its own outage instead of resuming as if nothing was missed.
|
|
389
|
+
*/
|
|
390
|
+
envelope(definition, runId, scheduledAt, gap) {
|
|
391
|
+
const lateBy = Math.max(0, this.deps.now() - scheduledAt);
|
|
344
392
|
return [
|
|
345
393
|
'[fleet-loop]',
|
|
346
394
|
`loop: ${definition.name}`,
|
|
347
395
|
`run: ${runId}`,
|
|
348
396
|
`scheduled_at: ${new Date(scheduledAt).toISOString()}`,
|
|
397
|
+
...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
|
|
398
|
+
...(gap ? [
|
|
399
|
+
`missed_occurrences: ${gap.count}`,
|
|
400
|
+
`missed_window: ${gap.fromAt}..${gap.throughAt}`,
|
|
401
|
+
`missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
|
|
402
|
+
] : []),
|
|
349
403
|
'origin: local-trusted-config',
|
|
350
404
|
'',
|
|
351
405
|
'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
|
|
352
406
|
'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
|
|
353
407
|
'configured policy and an existing authenticated proactive-report route authorize a material report.',
|
|
408
|
+
...(gap ? ['',
|
|
409
|
+
'This loop did not run for the window above: those occurrences were coalesced away while the role',
|
|
410
|
+
'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
|
|
411
|
+
'on — it is the record of your own outage, and no later pass will be told about it.',
|
|
412
|
+
] : []),
|
|
354
413
|
'',
|
|
355
414
|
definition.prompt,
|
|
356
415
|
].join('\n');
|
package/dist/loops/state.d.ts
CHANGED
|
@@ -8,11 +8,29 @@ export interface LoopCounts {
|
|
|
8
8
|
skippedBusy: number;
|
|
9
9
|
skippedMissed: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A coalesced run of occurrences that were never submitted, held until a run
|
|
13
|
+
* actually starts and can be told about it. Without it a dropped occurrence
|
|
14
|
+
* survives only as a counter, which says how many were lost but never when or
|
|
15
|
+
* for how long — and an oversight role cannot report an outage it cannot date.
|
|
16
|
+
*/
|
|
17
|
+
export interface LoopMissedGap {
|
|
18
|
+
/** Occurrences coalesced away, summed across every skip since the last run. */
|
|
19
|
+
count: number;
|
|
20
|
+
/** Nominal time of the earliest occurrence in the gap. */
|
|
21
|
+
fromAt: string;
|
|
22
|
+
/** Nominal time of the latest occurrence in the gap. */
|
|
23
|
+
throughAt: string;
|
|
24
|
+
/** When the manager noticed — the end of the outage, not of the last skip. */
|
|
25
|
+
detectedAt: string;
|
|
26
|
+
}
|
|
11
27
|
export interface LoopRuntimeState {
|
|
12
28
|
definitionHash: string;
|
|
13
29
|
promptHash: string;
|
|
14
30
|
enabled: boolean;
|
|
15
31
|
operatorDisabled: boolean;
|
|
32
|
+
/** Unreported gap, cleared by the first run that carries it. */
|
|
33
|
+
missedGap: LoopMissedGap | null;
|
|
16
34
|
nextScheduledAt: string;
|
|
17
35
|
nextDueAt: string;
|
|
18
36
|
lastScheduledAt: string | null;
|
package/dist/loops/state.js
CHANGED
|
@@ -100,6 +100,9 @@ export class ScheduledLoopStateStore {
|
|
|
100
100
|
if (old?.definitionHash === definition.definitionHash) {
|
|
101
101
|
next[definition.name] = {
|
|
102
102
|
...old, promptHash: definition.promptHash, enabled: definition.enabled,
|
|
103
|
+
// A file written before this field existed restores as undefined; an
|
|
104
|
+
// unreported gap is absent, not lost, so normalize rather than trust.
|
|
105
|
+
missedGap: old.missedGap ?? null,
|
|
103
106
|
};
|
|
104
107
|
}
|
|
105
108
|
else {
|
|
@@ -119,6 +122,7 @@ export class ScheduledLoopStateStore {
|
|
|
119
122
|
activeRunId: old?.activeRunId ?? null,
|
|
120
123
|
counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
|
|
121
124
|
operatorDisabled: old?.operatorDisabled ?? false,
|
|
125
|
+
missedGap: old?.missedGap ?? null,
|
|
122
126
|
};
|
|
123
127
|
}
|
|
124
128
|
if (recoverActive && next[definition.name].activeRunId) {
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCom
|
|
|
5
5
|
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
|
|
6
6
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
7
7
|
export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
|
|
8
|
+
/**
|
|
9
|
+
* How long a steering-started turn is presumed to still own the adapter after
|
|
10
|
+
* its last update. Such a turn has no prompt id, so it never reports a
|
|
11
|
+
* stopReason and there is no exact end to observe — silence is the only signal
|
|
12
|
+
* available, and this is the bound that turns it into a decision.
|
|
13
|
+
*
|
|
14
|
+
* Sized from the fleet's own scheduled-run history: across 1513 completed
|
|
15
|
+
* scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
|
|
16
|
+
* 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
|
|
17
|
+
* adapter is still working and re-admit a prompt into a busy turn, which is the
|
|
18
|
+
* FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
|
|
19
|
+
* long skips one best-effort maintenance tick, releasing too early SIGTERMs a
|
|
20
|
+
* live role.
|
|
21
|
+
*/
|
|
22
|
+
export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
|
|
8
23
|
/** Server-generated typed provenance followed by the exact human-authored body. */
|
|
9
24
|
export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
|
|
10
25
|
export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
|
|
@@ -33,6 +48,8 @@ export interface AcpSessionOptions {
|
|
|
33
48
|
controllerGraceMs?: number;
|
|
34
49
|
/** Test seam; production uses AFTER_TOOL_BOUNDARY_TIMEOUT_MS. */
|
|
35
50
|
afterToolBoundaryTimeoutMs?: number;
|
|
51
|
+
/** Test seam; production uses STEERING_OCCUPANCY_IDLE_MS. */
|
|
52
|
+
steeringOccupancyIdleMs?: number;
|
|
36
53
|
}
|
|
37
54
|
/**
|
|
38
55
|
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
@@ -60,6 +77,12 @@ export declare class AcpSession implements SessionHandle {
|
|
|
60
77
|
private connection;
|
|
61
78
|
private sessionId?;
|
|
62
79
|
private readiness;
|
|
80
|
+
/**
|
|
81
|
+
* Last non-replayed session update from the agent. `readiness` cannot answer
|
|
82
|
+
* "is this agent working" for a steered turn (FLEET-002), and this is the
|
|
83
|
+
* evidence that can.
|
|
84
|
+
*/
|
|
85
|
+
private lastUpdateAt?;
|
|
63
86
|
private lastError?;
|
|
64
87
|
private promptTail;
|
|
65
88
|
private queueDepth;
|
|
@@ -75,6 +98,13 @@ export declare class AcpSession implements SessionHandle {
|
|
|
75
98
|
private cancelEscalation?;
|
|
76
99
|
private cancelForceKill?;
|
|
77
100
|
private cancelRecoveryReason?;
|
|
101
|
+
/**
|
|
102
|
+
* Held while a steering-started turn is believed to own the adapter. It is a
|
|
103
|
+
* lease, not a latch: `steeringRelease` always fires, so the role can never be
|
|
104
|
+
* stranded busy by a wake whose turn ended without telling anyone.
|
|
105
|
+
*/
|
|
106
|
+
private steeringOccupied;
|
|
107
|
+
private steeringRelease?;
|
|
78
108
|
/**
|
|
79
109
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
80
110
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -96,6 +126,20 @@ export declare class AcpSession implements SessionHandle {
|
|
|
96
126
|
*/
|
|
97
127
|
private recoverOpenPrompts;
|
|
98
128
|
isAlive(): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Take the occupancy lease for a turn the adapter started on its own behalf.
|
|
131
|
+
* Refreshed by every adapter update, so it tracks work actually happening
|
|
132
|
+
* rather than a fixed guess at how long a wake takes.
|
|
133
|
+
*/
|
|
134
|
+
private holdSteeringOccupancy;
|
|
135
|
+
private refreshSteeringOccupancy;
|
|
136
|
+
/**
|
|
137
|
+
* Every exit from occupancy comes through here, including the ones that are
|
|
138
|
+
* not the timer: a real turn boundary, close, and adapter exit. A lease that
|
|
139
|
+
* can leak is worse than the bug it fixes — it would leave the role reporting
|
|
140
|
+
* `running` forever and starve scheduled admission permanently.
|
|
141
|
+
*/
|
|
142
|
+
private releaseSteeringOccupancy;
|
|
99
143
|
snapshot(): SessionSnapshot;
|
|
100
144
|
private toolCall;
|
|
101
145
|
private reserveTool;
|
package/dist/session/acp.js
CHANGED
|
@@ -16,6 +16,21 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
|
16
16
|
const CONTROLLER_GRACE_MS = 12_000;
|
|
17
17
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
18
18
|
export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
|
|
19
|
+
/**
|
|
20
|
+
* How long a steering-started turn is presumed to still own the adapter after
|
|
21
|
+
* its last update. Such a turn has no prompt id, so it never reports a
|
|
22
|
+
* stopReason and there is no exact end to observe — silence is the only signal
|
|
23
|
+
* available, and this is the bound that turns it into a decision.
|
|
24
|
+
*
|
|
25
|
+
* Sized from the fleet's own scheduled-run history: across 1513 completed
|
|
26
|
+
* scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
|
|
27
|
+
* 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
|
|
28
|
+
* adapter is still working and re-admit a prompt into a busy turn, which is the
|
|
29
|
+
* FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
|
|
30
|
+
* long skips one best-effort maintenance tick, releasing too early SIGTERMs a
|
|
31
|
+
* live role.
|
|
32
|
+
*/
|
|
33
|
+
export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
|
|
19
34
|
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
20
35
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
21
36
|
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
@@ -202,6 +217,12 @@ export class AcpSession {
|
|
|
202
217
|
connection;
|
|
203
218
|
sessionId;
|
|
204
219
|
readiness = 'starting';
|
|
220
|
+
/**
|
|
221
|
+
* Last non-replayed session update from the agent. `readiness` cannot answer
|
|
222
|
+
* "is this agent working" for a steered turn (FLEET-002), and this is the
|
|
223
|
+
* evidence that can.
|
|
224
|
+
*/
|
|
225
|
+
lastUpdateAt;
|
|
205
226
|
lastError;
|
|
206
227
|
promptTail = Promise.resolve();
|
|
207
228
|
queueDepth = 0;
|
|
@@ -217,6 +238,13 @@ export class AcpSession {
|
|
|
217
238
|
cancelEscalation;
|
|
218
239
|
cancelForceKill;
|
|
219
240
|
cancelRecoveryReason;
|
|
241
|
+
/**
|
|
242
|
+
* Held while a steering-started turn is believed to own the adapter. It is a
|
|
243
|
+
* lease, not a latch: `steeringRelease` always fires, so the role can never be
|
|
244
|
+
* stranded busy by a wake whose turn ended without telling anyone.
|
|
245
|
+
*/
|
|
246
|
+
steeringOccupied = false;
|
|
247
|
+
steeringRelease;
|
|
220
248
|
/**
|
|
221
249
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
222
250
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -247,6 +275,7 @@ export class AcpSession {
|
|
|
247
275
|
if (this.cancelForceKill)
|
|
248
276
|
clearTimeout(this.cancelForceKill);
|
|
249
277
|
this.cancelForceKill = undefined;
|
|
278
|
+
this.releaseSteeringOccupancy('adapter exited');
|
|
250
279
|
// Record the child's real exit code/signal. The tmux path can only see a
|
|
251
280
|
// shell's `$?`; here the truth is available, so keep it.
|
|
252
281
|
const classified = classifyChildExit(code, signal);
|
|
@@ -347,17 +376,60 @@ export class AcpSession {
|
|
|
347
376
|
// terminal fact (signal exits deliberately leave exitCode null).
|
|
348
377
|
return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
|
|
349
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Take the occupancy lease for a turn the adapter started on its own behalf.
|
|
381
|
+
* Refreshed by every adapter update, so it tracks work actually happening
|
|
382
|
+
* rather than a fixed guess at how long a wake takes.
|
|
383
|
+
*/
|
|
384
|
+
holdSteeringOccupancy() {
|
|
385
|
+
if (this.closing || !this.isAlive())
|
|
386
|
+
return;
|
|
387
|
+
this.steeringOccupied = true;
|
|
388
|
+
this.refreshSteeringOccupancy();
|
|
389
|
+
}
|
|
390
|
+
refreshSteeringOccupancy() {
|
|
391
|
+
if (!this.steeringOccupied)
|
|
392
|
+
return;
|
|
393
|
+
if (this.steeringRelease)
|
|
394
|
+
clearTimeout(this.steeringRelease);
|
|
395
|
+
this.steeringRelease = setTimeout(() => this.releaseSteeringOccupancy('adapter silent'), this.options.steeringOccupancyIdleMs ?? STEERING_OCCUPANCY_IDLE_MS);
|
|
396
|
+
this.steeringRelease.unref?.();
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Every exit from occupancy comes through here, including the ones that are
|
|
400
|
+
* not the timer: a real turn boundary, close, and adapter exit. A lease that
|
|
401
|
+
* can leak is worse than the bug it fixes — it would leave the role reporting
|
|
402
|
+
* `running` forever and starve scheduled admission permanently.
|
|
403
|
+
*/
|
|
404
|
+
releaseSteeringOccupancy(reason) {
|
|
405
|
+
if (this.steeringRelease)
|
|
406
|
+
clearTimeout(this.steeringRelease);
|
|
407
|
+
this.steeringRelease = undefined;
|
|
408
|
+
if (!this.steeringOccupied)
|
|
409
|
+
return;
|
|
410
|
+
this.steeringOccupied = false;
|
|
411
|
+
this.options.log(`[${this.options.name}] steering-started turn no longer holds the adapter (${reason})`);
|
|
412
|
+
}
|
|
350
413
|
snapshot() {
|
|
351
414
|
return {
|
|
352
415
|
backend: 'acp',
|
|
353
416
|
alive: this.isAlive(),
|
|
354
|
-
|
|
417
|
+
// A steering-started turn is real work with no prompt id. Reporting the
|
|
418
|
+
// session idle while it runs is what let the arbiter admit a scheduled
|
|
419
|
+
// prompt into a busy adapter, whose `session/prompt` then never returned
|
|
420
|
+
// a stopReason and ended in a cancellation deadline and a SIGTERM.
|
|
421
|
+
readiness: this.readiness === 'idle' && this.steeringOccupied
|
|
422
|
+
? 'running' : this.readiness,
|
|
355
423
|
sessionId: this.sessionId,
|
|
356
424
|
lastError: this.lastError,
|
|
357
425
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
358
426
|
runtimeModel: this.runtimeModel,
|
|
359
427
|
reasoningEffort: this.reasoningEffort,
|
|
360
428
|
permissionMode: this.options.permissionMode,
|
|
429
|
+
activity: {
|
|
430
|
+
activeToolCalls: this.activeToolCalls.size,
|
|
431
|
+
...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
|
|
432
|
+
},
|
|
361
433
|
};
|
|
362
434
|
}
|
|
363
435
|
toolCall(toolCallId) {
|
|
@@ -855,6 +927,7 @@ export class AcpSession {
|
|
|
855
927
|
if (this.controllerGrace)
|
|
856
928
|
clearTimeout(this.controllerGrace);
|
|
857
929
|
this.controllerGrace = undefined;
|
|
930
|
+
this.releaseSteeringOccupancy('session closed');
|
|
858
931
|
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
859
932
|
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
860
933
|
this.releaseAllTools();
|
|
@@ -1013,6 +1086,10 @@ export class AcpSession {
|
|
|
1013
1086
|
}
|
|
1014
1087
|
finally {
|
|
1015
1088
|
this.releaseAllTools();
|
|
1089
|
+
// A turn this client owned has ended, so the adapter has reported a
|
|
1090
|
+
// boundary: whatever a steering call started before it is over too. This
|
|
1091
|
+
// is the release path that does not depend on the silence timer.
|
|
1092
|
+
this.releaseSteeringOccupancy('turn boundary');
|
|
1016
1093
|
if (this.activeTurn?.id === turnId) {
|
|
1017
1094
|
this.activeTurn.settle();
|
|
1018
1095
|
if (this.cancelEscalation)
|
|
@@ -1035,6 +1112,12 @@ export class AcpSession {
|
|
|
1035
1112
|
]);
|
|
1036
1113
|
if (response.outcome === 'failed')
|
|
1037
1114
|
return turnResult(false, 'failed', 'ACP steering failed');
|
|
1115
|
+
// `injected` joined a turn this client already owns and will settle.
|
|
1116
|
+
// `startedNewTurn` created one nobody owns: the adapter is working and
|
|
1117
|
+
// will never answer for it, so admission has to learn about it here or
|
|
1118
|
+
// not at all.
|
|
1119
|
+
if (response.outcome === 'startedNewTurn')
|
|
1120
|
+
this.holdSteeringOccupancy();
|
|
1038
1121
|
return turnResult(true, 'inconclusive', response.outcome);
|
|
1039
1122
|
}
|
|
1040
1123
|
catch (error) {
|
|
@@ -1219,6 +1302,14 @@ export class AcpSession {
|
|
|
1219
1302
|
&& params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
|
|
1220
1303
|
}
|
|
1221
1304
|
recordUpdate(update) {
|
|
1305
|
+
// Replayed history is not current activity: `session/load` would otherwise
|
|
1306
|
+
// make a cold session look like it had just been working. The same reason
|
|
1307
|
+
// keeps it from extending the steering lease, which is evidence the adapter
|
|
1308
|
+
// is working right now — for a steering-started turn, the only evidence.
|
|
1309
|
+
if (!this.replaying) {
|
|
1310
|
+
this.lastUpdateAt = new Date().toISOString();
|
|
1311
|
+
this.refreshSteeringOccupancy();
|
|
1312
|
+
}
|
|
1222
1313
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1223
1314
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1224
1315
|
? this.codexMessagePhase(update) : undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionActivity } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How long after the agent's last session update it still counts as working.
|
|
4
|
+
*
|
|
5
|
+
* FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
|
|
6
|
+
* an entire turn that fleet never receives a `session/prompt` response for (ACP
|
|
7
|
+
* has no turn-end session update), so `readiness` stays `idle` throughout. Tool
|
|
8
|
+
* reservations and update recency are the only activity evidence fleet holds.
|
|
9
|
+
* The trade-off is deliberate and one-directional: at worst a role reads busy
|
|
10
|
+
* for one window after it genuinely stopped, instead of reading ready — or
|
|
11
|
+
* being classified stalled — while it is executing tools.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ACTIVITY_WINDOW_MS = 60000;
|
|
14
|
+
export type ActivityState = 'active' | 'quiet' | 'unobservable';
|
|
15
|
+
export interface ObservedActivity {
|
|
16
|
+
state: ActivityState;
|
|
17
|
+
activeToolCalls?: number;
|
|
18
|
+
lastUpdateAt?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
|
|
22
|
+
* cannot see the agent (tmux) has no evidence, and no evidence must never be
|
|
23
|
+
* reported as "doing nothing".
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyActivity(activity: SessionActivity | undefined, now?: number): ObservedActivity;
|
|
26
|
+
/**
|
|
27
|
+
* One operator-facing line that never lets turn occupancy pose as liveness:
|
|
28
|
+
* the readiness value is labelled as the turn field it is, and the activity
|
|
29
|
+
* verdict is stated separately with the evidence behind it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function describeSessionState(readiness: string | undefined, activity: SessionActivity | undefined, now?: number): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long after the agent's last session update it still counts as working.
|
|
3
|
+
*
|
|
4
|
+
* FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
|
|
5
|
+
* an entire turn that fleet never receives a `session/prompt` response for (ACP
|
|
6
|
+
* has no turn-end session update), so `readiness` stays `idle` throughout. Tool
|
|
7
|
+
* reservations and update recency are the only activity evidence fleet holds.
|
|
8
|
+
* The trade-off is deliberate and one-directional: at worst a role reads busy
|
|
9
|
+
* for one window after it genuinely stopped, instead of reading ready — or
|
|
10
|
+
* being classified stalled — while it is executing tools.
|
|
11
|
+
*/
|
|
12
|
+
export const ACTIVITY_WINDOW_MS = 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
|
|
15
|
+
* cannot see the agent (tmux) has no evidence, and no evidence must never be
|
|
16
|
+
* reported as "doing nothing".
|
|
17
|
+
*/
|
|
18
|
+
export function classifyActivity(activity, now = Date.now()) {
|
|
19
|
+
if (!activity)
|
|
20
|
+
return { state: 'unobservable' };
|
|
21
|
+
const lastUpdate = activity.lastUpdateAt ? Date.parse(activity.lastUpdateAt) : NaN;
|
|
22
|
+
const recent = Number.isFinite(lastUpdate) && now - lastUpdate <= ACTIVITY_WINDOW_MS;
|
|
23
|
+
return {
|
|
24
|
+
state: activity.activeToolCalls > 0 || recent ? 'active' : 'quiet',
|
|
25
|
+
activeToolCalls: activity.activeToolCalls,
|
|
26
|
+
...(activity.lastUpdateAt ? { lastUpdateAt: activity.lastUpdateAt } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One operator-facing line that never lets turn occupancy pose as liveness:
|
|
31
|
+
* the readiness value is labelled as the turn field it is, and the activity
|
|
32
|
+
* verdict is stated separately with the evidence behind it.
|
|
33
|
+
*/
|
|
34
|
+
export function describeSessionState(readiness, activity, now = Date.now()) {
|
|
35
|
+
const observed = classifyActivity(activity, now);
|
|
36
|
+
const evidence = [];
|
|
37
|
+
if (observed.activeToolCalls)
|
|
38
|
+
evidence.push(`${observed.activeToolCalls} tool calls in flight`);
|
|
39
|
+
if (observed.lastUpdateAt) {
|
|
40
|
+
const age = Math.max(0, Math.round((now - Date.parse(observed.lastUpdateAt)) / 1000));
|
|
41
|
+
if (Number.isFinite(age))
|
|
42
|
+
evidence.push(`last agent update ${age}s ago`);
|
|
43
|
+
}
|
|
44
|
+
const detail = observed.state === 'unobservable'
|
|
45
|
+
? 'no agent-side evidence on this backend'
|
|
46
|
+
: evidence.join(', ') || 'no updates yet';
|
|
47
|
+
return `turn: ${readiness ?? 'unknown'} (turn occupancy only) | activity: ${observed.state} (${detail})`;
|
|
48
|
+
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { SessionBackendId } from '../config.js';
|
|
2
2
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* TURN OCCUPANCY, and nothing else: `idle` means no fleet-tracked turn is in
|
|
5
|
+
* flight, which is exactly the question `arbiter.tryScheduled` asks before it
|
|
6
|
+
* admits a prompt. It is NOT a claim that the agent is doing nothing — a wake
|
|
7
|
+
* delivered through the `_session/steering` extension answers `startedNewTurn`
|
|
8
|
+
* and runs a whole turn that fleet never gets a `session/prompt` response for
|
|
9
|
+
* (ACP has no turn-end session update), so `readiness` stays `idle` for its
|
|
10
|
+
* entire duration. Anything reporting activity or liveness to a human must
|
|
11
|
+
* corroborate with `SessionSnapshot.activity` instead of reading `idle` here as
|
|
12
|
+
* "not working".
|
|
13
|
+
*/
|
|
3
14
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
4
15
|
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
5
16
|
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
|
|
@@ -170,6 +181,19 @@ export interface SessionSnapshot {
|
|
|
170
181
|
/** Exact harness-native approval/permission mode used by this runner. */
|
|
171
182
|
nativeMode: string;
|
|
172
183
|
};
|
|
184
|
+
/**
|
|
185
|
+
* Observed agent activity, independent of turn occupancy: the evidence a
|
|
186
|
+
* human-facing surface needs before calling a role idle. Absent on backends
|
|
187
|
+
* that cannot observe the agent at all (tmux), which is itself honest — no
|
|
188
|
+
* evidence is not evidence of inactivity.
|
|
189
|
+
*/
|
|
190
|
+
activity?: SessionActivity;
|
|
191
|
+
}
|
|
192
|
+
export interface SessionActivity {
|
|
193
|
+
/** ACP tool calls currently reserved (lifecycle open or permission pending). */
|
|
194
|
+
activeToolCalls: number;
|
|
195
|
+
/** When the agent last sent ANY session update, replay excluded. */
|
|
196
|
+
lastUpdateAt?: string;
|
|
173
197
|
}
|
|
174
198
|
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
175
199
|
/** What a settled permission request resolved to. */
|
|
@@ -80,6 +80,13 @@ export function generateWatchdogBriefing(opts) {
|
|
|
80
80
|
L.push('- `healthy` — alive, on-briefing, recent progress.');
|
|
81
81
|
L.push('- `idle` — alive, nothing assigned or nothing to do. Not an anomaly.');
|
|
82
82
|
L.push('- `stale` = no worklog append and no console progress for ≥ 3 intervals.');
|
|
83
|
+
L.push('');
|
|
84
|
+
L.push('`session.readiness` from `ours-fleet status` is TURN OCCUPANCY, not activity: a mail');
|
|
85
|
+
L.push('wake delivered by ACP steering runs an entire turn while readiness stays `idle`. Never');
|
|
86
|
+
L.push('report `idle` or `stale` from `readiness=idle` alone — corroborate with the');
|
|
87
|
+
L.push('`activity:` line of the same `status` output (`active` means the agent is working),');
|
|
88
|
+
L.push('the worklog, or `ours-fleet peek`. `activity: unobservable` is missing evidence, not');
|
|
89
|
+
L.push('an idle agent.');
|
|
83
90
|
L.push('- `blocked` = waiting on a permission/prompt/modal longer than one interval.');
|
|
84
91
|
L.push('- `off_briefing` — activity contradicts the briefing (wrong repo, out-of-scope work,');
|
|
85
92
|
L.push(' ignored routine).');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
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",
|