@ours.network/fleet 0.9.1 → 0.9.2
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/config.d.ts +7 -0
- package/dist/config.js +7 -1
- package/dist/monitor.d.ts +27 -0
- package/dist/monitor.js +81 -1
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface MonitorConfig {
|
|
|
13
13
|
wake_sources: string[];
|
|
14
14
|
batch_ms: number;
|
|
15
15
|
inject: InjectMode;
|
|
16
|
+
/**
|
|
17
|
+
* Consecutive delivered wakes that must end in an `API Error:`-terminated turn
|
|
18
|
+
* (with no completed turn in between) before `.monitor-status` degrades to
|
|
19
|
+
* `turns failing (api error)` — the refusal-wedge detector (issue #19). Must be
|
|
20
|
+
* a positive integer; resolved default is 3. Optional so old snapshots resolve.
|
|
21
|
+
*/
|
|
22
|
+
turn_fail_threshold?: number;
|
|
16
23
|
}
|
|
17
24
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
18
25
|
export declare const DEFAULT_WAKE_SOURCES: NotifyEventType[];
|
package/dist/config.js
CHANGED
|
@@ -10,9 +10,10 @@ export const NOTIFY_EVENT_TYPES = [
|
|
|
10
10
|
];
|
|
11
11
|
/** Default wake sources when a role does not list its own (design §2). */
|
|
12
12
|
export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
|
|
13
|
-
const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject'];
|
|
13
|
+
const MONITOR_KEYS = ['enabled', 'wake_sources', 'batch_ms', 'inject', 'turn_fail_threshold'];
|
|
14
14
|
const INJECT_MODES = ['notification', 'full'];
|
|
15
15
|
const MONITOR_DEFAULT_BATCH_MS = 2000;
|
|
16
|
+
const MONITOR_DEFAULT_TURN_FAIL_THRESHOLD = 3;
|
|
16
17
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
17
18
|
/** Validate a raw (role-level or merged) `monitor:` block; returns human-readable problems. */
|
|
18
19
|
export function validateMonitorConfig(raw) {
|
|
@@ -30,6 +31,10 @@ export function validateMonitorConfig(raw) {
|
|
|
30
31
|
problems.push('monitor.batch_ms: must be a non-negative number');
|
|
31
32
|
if (m.inject !== undefined && !INJECT_MODES.includes(m.inject))
|
|
32
33
|
problems.push(`monitor.inject: invalid value '${m.inject}'; allowed: ${INJECT_MODES.join(', ')}`);
|
|
34
|
+
if (m.turn_fail_threshold !== undefined
|
|
35
|
+
&& (typeof m.turn_fail_threshold !== 'number' || !Number.isInteger(m.turn_fail_threshold)
|
|
36
|
+
|| m.turn_fail_threshold < 1))
|
|
37
|
+
problems.push('monitor.turn_fail_threshold: must be a positive integer');
|
|
33
38
|
if (m.wake_sources !== undefined) {
|
|
34
39
|
if (!Array.isArray(m.wake_sources))
|
|
35
40
|
problems.push('monitor.wake_sources: must be a list');
|
|
@@ -168,6 +173,7 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
|
|
|
168
173
|
wake_sources: merged.wake_sources ?? [...DEFAULT_WAKE_SOURCES],
|
|
169
174
|
batch_ms: merged.batch_ms ?? MONITOR_DEFAULT_BATCH_MS,
|
|
170
175
|
inject: merged.inject ?? 'notification',
|
|
176
|
+
turn_fail_threshold: merged.turn_fail_threshold ?? MONITOR_DEFAULT_TURN_FAIL_THRESHOLD,
|
|
171
177
|
};
|
|
172
178
|
}
|
|
173
179
|
export function findRole(cfg, name) {
|
package/dist/monitor.d.ts
CHANGED
|
@@ -85,6 +85,21 @@ export declare function formatNotificationLine(events: NotifyEvent[]): string;
|
|
|
85
85
|
* open question (a): refine empirically). A running turn is NOT modal.
|
|
86
86
|
*/
|
|
87
87
|
export declare function looksModal(pane: string): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
|
|
90
|
+
* Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
|
|
91
|
+
* refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
|
|
92
|
+
* trailing idle composer redrawn beneath it (design §3.2, refine empirically).
|
|
93
|
+
* The N-consecutive threshold in the Monitor debounces the odd false match.
|
|
94
|
+
*/
|
|
95
|
+
export declare function looksApiError(pane: string): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Heuristic: is a turn still RUNNING in this pane? Claude Code shows a live
|
|
98
|
+
* "esc to interrupt" footer (often with an elapsed-seconds meter) while a turn
|
|
99
|
+
* streams. Absence of any running marker — and no API error — means the turn has
|
|
100
|
+
* settled (completed). Kept a positive check so a quiet idle pane reads as done.
|
|
101
|
+
*/
|
|
102
|
+
export declare function looksRunning(pane: string): boolean;
|
|
88
103
|
export interface MonitorOpts {
|
|
89
104
|
name: string;
|
|
90
105
|
agentDir: string;
|
|
@@ -109,6 +124,8 @@ export declare class Monitor {
|
|
|
109
124
|
private stopped;
|
|
110
125
|
private bootDeadline;
|
|
111
126
|
private currentAbort;
|
|
127
|
+
private apiErrorStreak;
|
|
128
|
+
private readonly turnFailThreshold;
|
|
112
129
|
constructor(o: MonitorOpts);
|
|
113
130
|
/** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
|
|
114
131
|
prime(): Promise<void>;
|
|
@@ -118,6 +135,16 @@ export declare class Monitor {
|
|
|
118
135
|
/** Gather stragglers arriving within batch_ms so a burst lands as one line. */
|
|
119
136
|
private coalesce;
|
|
120
137
|
private deliver;
|
|
138
|
+
/**
|
|
139
|
+
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
140
|
+
* into the API-error streak and republish `.monitor-status`. A completed turn
|
|
141
|
+
* (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
|
|
142
|
+
* grows it. Once the streak reaches the threshold the status degrades; a later
|
|
143
|
+
* completed turn flips it back to armed. Detection only — no remediation (#19).
|
|
144
|
+
*/
|
|
145
|
+
private observeTurnOutcome;
|
|
146
|
+
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
147
|
+
private recordTurn;
|
|
121
148
|
/** Block until the console can accept input; classify offline/stopped/ready. */
|
|
122
149
|
private awaitInjectable;
|
|
123
150
|
private doFetch;
|
package/dist/monitor.js
CHANGED
|
@@ -13,6 +13,11 @@ const BACKOFF_STEP_MS = 1_000;
|
|
|
13
13
|
const BACKOFF_MAX_MS = 5_000;
|
|
14
14
|
const PREFIX = '[fleet-monitor]';
|
|
15
15
|
const MAX_LINE = 260;
|
|
16
|
+
// Turn-outcome observation (issue #19): after a delivered wake, watch the pane
|
|
17
|
+
// until the triggered turn settles, then classify it. Code constants (not config):
|
|
18
|
+
const TURN_OBSERVE_POLLS = 20; // give up after ~POLLS × INTERVAL of a still-running turn
|
|
19
|
+
const TURN_OBSERVE_INTERVAL_MS = 1_500;
|
|
20
|
+
const DEFAULT_TURN_FAIL_THRESHOLD = 3; // fallback when the resolved config omits it
|
|
16
21
|
class AuthError extends Error {
|
|
17
22
|
}
|
|
18
23
|
/** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
|
|
@@ -151,6 +156,31 @@ export function looksModal(pane) {
|
|
|
151
156
|
const hasNumbered = /(^|\n)\s*[❯>]?\s*\d+[.)]\s+\S/.test(pane);
|
|
152
157
|
return hasPointer && hasNumbered;
|
|
153
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
|
|
161
|
+
* Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
|
|
162
|
+
* refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
|
|
163
|
+
* trailing idle composer redrawn beneath it (design §3.2, refine empirically).
|
|
164
|
+
* The N-consecutive threshold in the Monitor debounces the odd false match.
|
|
165
|
+
*/
|
|
166
|
+
export function looksApiError(pane) {
|
|
167
|
+
const tail = pane.split('\n').slice(-15).join('\n');
|
|
168
|
+
return /\bAPI Error\b/i.test(tail);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Heuristic: is a turn still RUNNING in this pane? Claude Code shows a live
|
|
172
|
+
* "esc to interrupt" footer (often with an elapsed-seconds meter) while a turn
|
|
173
|
+
* streams. Absence of any running marker — and no API error — means the turn has
|
|
174
|
+
* settled (completed). Kept a positive check so a quiet idle pane reads as done.
|
|
175
|
+
*/
|
|
176
|
+
export function looksRunning(pane) {
|
|
177
|
+
const tail = pane.split('\n').slice(-6).join('\n');
|
|
178
|
+
if (/esc to interrupt/i.test(tail))
|
|
179
|
+
return true; // Claude Code's running footer
|
|
180
|
+
if (/\(\s*\d+s\b/.test(tail))
|
|
181
|
+
return true; // "(12s · … tokens)" elapsed meter
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
154
184
|
/** Is the injected line still sitting unsubmitted in the composer (bottom of pane)? */
|
|
155
185
|
function stillInComposer(pane, line) {
|
|
156
186
|
const frag = line.slice(0, 48);
|
|
@@ -169,6 +199,10 @@ export class Monitor {
|
|
|
169
199
|
stopped = false;
|
|
170
200
|
bootDeadline = 0;
|
|
171
201
|
currentAbort = null;
|
|
202
|
+
// Refusal-wedge detector (issue #19): consecutive delivered wakes whose turn
|
|
203
|
+
// ended in an API error with no completed turn in between.
|
|
204
|
+
apiErrorStreak = 0;
|
|
205
|
+
turnFailThreshold;
|
|
172
206
|
constructor(o) {
|
|
173
207
|
this.name = o.name;
|
|
174
208
|
this.cfg = o.cfg;
|
|
@@ -176,6 +210,8 @@ export class Monitor {
|
|
|
176
210
|
this.ep = resolveEndpoint(o.deps.env);
|
|
177
211
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
178
212
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
213
|
+
const n = o.cfg.turn_fail_threshold;
|
|
214
|
+
this.turnFailThreshold = typeof n === 'number' && n >= 1 ? n : DEFAULT_TURN_FAIL_THRESHOLD;
|
|
179
215
|
}
|
|
180
216
|
/** Prime at the stream tip (or resume a persisted cursor if the daemon is down). */
|
|
181
217
|
async prime() {
|
|
@@ -274,7 +310,51 @@ export class Monitor {
|
|
|
274
310
|
}
|
|
275
311
|
await this.deps.tmux.sendKey(this.name, 'Enter');
|
|
276
312
|
}
|
|
277
|
-
|
|
313
|
+
if (!delivered) {
|
|
314
|
+
this.setStatus('degraded: injection unverified');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// The wake landed and a turn started; observe how that turn terminates so a
|
|
318
|
+
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
319
|
+
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
320
|
+
await this.observeTurnOutcome(pid);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
324
|
+
* into the API-error streak and republish `.monitor-status`. A completed turn
|
|
325
|
+
* (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
|
|
326
|
+
* grows it. Once the streak reaches the threshold the status degrades; a later
|
|
327
|
+
* completed turn flips it back to armed. Detection only — no remediation (#19).
|
|
328
|
+
*/
|
|
329
|
+
async observeTurnOutcome(pid) {
|
|
330
|
+
for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
|
|
331
|
+
if (this.stopped)
|
|
332
|
+
return; // shutting down — leave status
|
|
333
|
+
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
334
|
+
return; // loop marks offline
|
|
335
|
+
const pane = await safeCapture(this.deps.tmux, this.name);
|
|
336
|
+
if (looksApiError(pane)) {
|
|
337
|
+
this.recordTurn('api-error');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!looksRunning(pane)) {
|
|
341
|
+
this.recordTurn('completed');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
|
|
345
|
+
}
|
|
346
|
+
this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
|
|
347
|
+
}
|
|
348
|
+
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
349
|
+
recordTurn(outcome) {
|
|
350
|
+
if (outcome === 'api-error')
|
|
351
|
+
this.apiErrorStreak++;
|
|
352
|
+
else if (outcome === 'completed')
|
|
353
|
+
this.apiErrorStreak = 0;
|
|
354
|
+
// 'inconclusive' leaves the streak (and therefore the status) unchanged.
|
|
355
|
+
this.setStatus(this.apiErrorStreak >= this.turnFailThreshold
|
|
356
|
+
? 'degraded: turns failing (api error)'
|
|
357
|
+
: 'armed');
|
|
278
358
|
}
|
|
279
359
|
/** Block until the console can accept input; classify offline/stopped/ready. */
|
|
280
360
|
async awaitInjectable(pid) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|