@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 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
- this.setStatus(delivered ? 'armed' : 'degraded: injection unverified');
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.1",
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",