@phnx-labs/agents-cli 1.22.1 → 1.22.3
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/CHANGELOG.md +46 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/exec.js +1 -1
- package/dist/commands/projects.js +25 -1
- package/dist/commands/secrets.js +9 -5
- package/dist/commands/teams.js +2 -2
- package/dist/commands/watchdog.js +64 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/projects.d.ts +15 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/rotate.d.ts +16 -1
- package/dist/lib/rotate.js +33 -7
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +50 -0
- package/dist/lib/share/config.d.ts +14 -6
- package/dist/lib/share/config.js +23 -5
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/watchdog/rotate.d.ts +218 -0
- package/dist/lib/watchdog/rotate.js +378 -0
- package/dist/lib/watchdog/runner.d.ts +33 -1
- package/dist/lib/watchdog/runner.js +303 -0
- package/package.json +1 -1
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
|
|
23
23
|
* - last-tick.json — the full outcome list from the most recent tick.
|
|
24
24
|
* - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
|
|
25
|
+
* - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
|
|
26
|
+
* - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
|
|
25
27
|
*
|
|
26
28
|
* The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
|
|
27
29
|
* re-implemented; the runner only supplies its I/O (sessions, tails, clock,
|
|
@@ -34,6 +36,7 @@ import { type InjectRail } from '../terminal/resolve.js';
|
|
|
34
36
|
import { type InjectResult, type InjectTarget } from '../terminal/inject.js';
|
|
35
37
|
import { type StallStatus, type WatchdogCandidate } from './watchdog.js';
|
|
36
38
|
import { type OpenBlock } from '../feed.js';
|
|
39
|
+
import { type RotateGateResult, type RotatePhase, type RotateState } from './rotate.js';
|
|
37
40
|
/** Per-session policy sentinel. `keep` is the default (watchdog may nudge). */
|
|
38
41
|
export type WatchdogPolicy = 'off' | 'keep' | 'handsoff';
|
|
39
42
|
/** Stall / cooldown / dormant thresholds (ms). Defaults mirror read.ts. */
|
|
@@ -92,9 +95,34 @@ export interface WatchdogTickOptions {
|
|
|
92
95
|
/** Inject primitive. Default injectIntoTerminal — tests capture the resolved target. */
|
|
93
96
|
injectFn?: (target: InjectTarget, text: string, opts: {
|
|
94
97
|
dryRun?: boolean;
|
|
98
|
+
enter?: boolean;
|
|
95
99
|
}) => Promise<InjectResult>;
|
|
96
100
|
/** Override the canonical watchdog.log path (tests point at a tmp file). */
|
|
97
101
|
logPath?: string;
|
|
102
|
+
/**
|
|
103
|
+
* In-place rotate of rate-limited sessions. Default = `watchdog.rotate` in
|
|
104
|
+
* agents.yaml (on). Only acts when `nudge` is also set (a dry tick never
|
|
105
|
+
* rotates).
|
|
106
|
+
*/
|
|
107
|
+
rotate?: boolean;
|
|
108
|
+
/** Bounded wait for the relaunched TUI to come live. Default 60s. */
|
|
109
|
+
rotateReadinessMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* First-party health gate run BEFORE rotating. Default defaultRotateGate()
|
|
112
|
+
* (collectHarnessCandidates + pickHarnessWeighted — the same selection
|
|
113
|
+
* `agents run auto` makes). Tests inject a synthetic verdict.
|
|
114
|
+
*/
|
|
115
|
+
rotateGate?: () => Promise<RotateGateResult>;
|
|
116
|
+
/** New session id for the relaunch. Default crypto.randomUUID(). Tests pin it. */
|
|
117
|
+
newSessionIdFor?: () => string;
|
|
118
|
+
/**
|
|
119
|
+
* Readiness probe: is the relaunched TUI live? Default: a transcript for the
|
|
120
|
+
* new session id resolves under a known harness layout, OR a fresh active
|
|
121
|
+
* session (started after the rotate began) exists in this tick's scan.
|
|
122
|
+
*/
|
|
123
|
+
tuiLiveFor?: (state: RotateState, sessions: ActiveSession[]) => boolean;
|
|
124
|
+
/** Delay between exit-sequence keystrokes. Default 300ms; tests set 0. */
|
|
125
|
+
rotateKeyDelayMs?: number;
|
|
98
126
|
}
|
|
99
127
|
/** The smart brain seam: a stalled candidate in, a nudge decision out. */
|
|
100
128
|
export type SmartDecider = (session: ActiveSession, candidate: WatchdogCandidate) => Promise<NudgeDecision>;
|
|
@@ -110,8 +138,10 @@ export interface SessionOutcome {
|
|
|
110
138
|
/** stalled duration (ms), when stalled. */
|
|
111
139
|
stalledForMs?: number;
|
|
112
140
|
policy: WatchdogPolicy;
|
|
113
|
-
decision: 'nudge' | 'skip';
|
|
141
|
+
decision: 'nudge' | 'skip' | 'rotate';
|
|
114
142
|
reason: string;
|
|
143
|
+
/** The rotate machine's phase after this tick (decision === 'rotate'). */
|
|
144
|
+
rotatePhase?: RotatePhase;
|
|
115
145
|
/** The resolved rail, when delivered by injecting into a terminal split. */
|
|
116
146
|
rail?: InjectRail;
|
|
117
147
|
/** How the nudge was (or would be) delivered: inject | mailbox | resume. */
|
|
@@ -137,6 +167,8 @@ export interface WatchdogTickResult {
|
|
|
137
167
|
nudged: number;
|
|
138
168
|
unaddressable: number;
|
|
139
169
|
skipped: number;
|
|
170
|
+
/** Sessions the tick moved through the rotate machine (any phase). */
|
|
171
|
+
rotating: number;
|
|
140
172
|
};
|
|
141
173
|
/**
|
|
142
174
|
* RUSH-2007 Layer C: per-session presence reconciled from this tick's active
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* - flags.json — { [sessionId]: { reason, host, atMs } } — un-addressable stalls.
|
|
23
23
|
* - last-tick.json — the full outcome list from the most recent tick.
|
|
24
24
|
* - policy/<sessionId> — per-session sentinel: off | keep | handsoff.
|
|
25
|
+
* - rotate/<sessionId>.json — the in-place rotate state machine (rotate.ts).
|
|
26
|
+
* - rotate-skips.json — zero-healthy skip suppression: { [sessionId]: suppressUntilMs }.
|
|
25
27
|
*
|
|
26
28
|
* The pure logic (classifyTerminal / isLikelyTrulyBlocked) is imported and never
|
|
27
29
|
* re-implemented; the runner only supplies its I/O (sessions, tails, clock,
|
|
@@ -30,6 +32,7 @@
|
|
|
30
32
|
*/
|
|
31
33
|
import * as fs from 'fs';
|
|
32
34
|
import * as path from 'path';
|
|
35
|
+
import * as crypto from 'crypto';
|
|
33
36
|
import { getActiveSessions } from '../session/active.js';
|
|
34
37
|
import { reconcilePresence, loadPresence, savePresence, observedFromActive, } from '../session/presence.js';
|
|
35
38
|
import { resolveInjectTargetForSession, } from '../terminal/resolve.js';
|
|
@@ -44,6 +47,7 @@ import { mailboxIdForActiveSession } from '../mailbox-target.js';
|
|
|
44
47
|
import { readBlock, blockIdForSession } from '../feed.js';
|
|
45
48
|
import { summarizeWatchdogTail } from './watchdogTail.js';
|
|
46
49
|
import { appendWatchdogEvents } from './log.js';
|
|
50
|
+
import { buildRotateLaunchCommand, buildRotateReplayText, classifyTailForRotate, defaultRotateGate, defaultTuiLiveFor, exitSequenceFor, isInflightPhase, isWatchdogRotateEnabled, listInflightRotates, readRotateState, recordRotateSkip, shouldLogRotateSkip, writeRotateState, DEFAULT_ROTATE_READINESS_MS, DEFAULT_ROTATE_SKIP_COOLDOWN_MS, DEFAULT_ROTATE_FAILED_COOLDOWN_MS, } from './rotate.js';
|
|
47
51
|
export const DEFAULT_THRESHOLDS = {
|
|
48
52
|
stallMs: WATCHDOG_STALL_MS,
|
|
49
53
|
cooldownMs: WATCHDOG_COOLDOWN_MS,
|
|
@@ -250,6 +254,87 @@ async function deliverViaResume(session, text) {
|
|
|
250
254
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
251
255
|
}
|
|
252
256
|
}
|
|
257
|
+
/** The one-line reason a rotate outcome carries. */
|
|
258
|
+
function rotateOutcomeReason(s) {
|
|
259
|
+
switch (s.phase) {
|
|
260
|
+
case 'awaiting-tui':
|
|
261
|
+
return `rotate in flight — awaiting new session ${s.newSessionId} TUI (deadline ${new Date(s.deadlineMs).toISOString()})`;
|
|
262
|
+
case 'done':
|
|
263
|
+
return `rotated → ${s.newSessionId}; replayed resume`;
|
|
264
|
+
case 'failed':
|
|
265
|
+
return `rotate failed: ${s.error ?? 'unknown'}`;
|
|
266
|
+
default:
|
|
267
|
+
return `rotate in flight (${s.phase})`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Advance ONE in-flight rotate by a single tick. Only `awaiting-tui` normally
|
|
272
|
+
* spans ticks (the exit sequence kills the old session, so the machine resumes
|
|
273
|
+
* here on the next pass); `exiting`/`launching`/`replaying` persisted on disk
|
|
274
|
+
* are crash residue — the first two fall through to the readiness probe (the
|
|
275
|
+
* launch may or may not have landed; the probe/deadline decides), a `replaying`
|
|
276
|
+
* residue re-delivers the replay. On the readiness deadline the session is
|
|
277
|
+
* FAILED + flagged and the machine stops — never blind-type into a dead shell.
|
|
278
|
+
*/
|
|
279
|
+
async function advanceRotate(state, deps) {
|
|
280
|
+
let s = state;
|
|
281
|
+
const fail = (error) => {
|
|
282
|
+
s = {
|
|
283
|
+
...s, phase: 'failed', error, updatedAtMs: deps.nowMs,
|
|
284
|
+
// Failed-rotate retry cooldown (honored at begin): without it a session
|
|
285
|
+
// whose old TUI ignored the exit sequence re-begins and deadline-fails
|
|
286
|
+
// every tick forever.
|
|
287
|
+
suppressUntilMs: deps.nowMs + DEFAULT_ROTATE_FAILED_COOLDOWN_MS,
|
|
288
|
+
};
|
|
289
|
+
writeRotateState(deps.dir, s);
|
|
290
|
+
deps.flags[s.sessionId] = { reason: `rotate failed: ${error}`, host: s.host, atMs: deps.nowMs };
|
|
291
|
+
deps.logEvents.push({
|
|
292
|
+
ts: deps.nowMs, kind: 'rotate', terminalId: s.sessionId, agentType: s.agent,
|
|
293
|
+
message: `rotate failed: ${s.sessionId} — ${error}`,
|
|
294
|
+
});
|
|
295
|
+
return s;
|
|
296
|
+
};
|
|
297
|
+
if (s.phase === 'exiting' || s.phase === 'launching') {
|
|
298
|
+
s = { ...s, phase: 'awaiting-tui', updatedAtMs: deps.nowMs };
|
|
299
|
+
writeRotateState(deps.dir, s);
|
|
300
|
+
}
|
|
301
|
+
if (s.phase === 'awaiting-tui') {
|
|
302
|
+
if (!deps.tuiLiveFor(s, deps.sessions)) {
|
|
303
|
+
if (deps.nowMs > s.deadlineMs) {
|
|
304
|
+
return fail(`new session ${s.newSessionId} TUI not live within the readiness budget — nothing was typed ` +
|
|
305
|
+
`into a possibly-dead shell; the terminal may sit at a BARE SHELL now: relaunch manually with ` +
|
|
306
|
+
`\`agents run auto\` (there is no automatic recovery)`);
|
|
307
|
+
}
|
|
308
|
+
return s; // still inside the bounded wait
|
|
309
|
+
}
|
|
310
|
+
s = { ...s, phase: 'replaying', updatedAtMs: deps.nowMs };
|
|
311
|
+
writeRotateState(deps.dir, s);
|
|
312
|
+
}
|
|
313
|
+
if (s.phase === 'replaying') {
|
|
314
|
+
if (!deps.mayInject)
|
|
315
|
+
return s; // dry tick: hold, never inject
|
|
316
|
+
let ok = true;
|
|
317
|
+
let error;
|
|
318
|
+
try {
|
|
319
|
+
const r = await deps.injectFn(s.target, buildRotateReplayText(s.sessionId), { dryRun: deps.injectDryRun });
|
|
320
|
+
ok = r.ok;
|
|
321
|
+
error = r.error;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
ok = false;
|
|
325
|
+
error = err instanceof Error ? err.message : String(err);
|
|
326
|
+
}
|
|
327
|
+
if (!ok)
|
|
328
|
+
return fail(`replay inject failed: ${error ?? 'unknown error'}`);
|
|
329
|
+
s = { ...s, phase: 'done', updatedAtMs: deps.nowMs };
|
|
330
|
+
writeRotateState(deps.dir, s);
|
|
331
|
+
deps.logEvents.push({
|
|
332
|
+
ts: deps.nowMs, kind: 'rotate', terminalId: s.sessionId, agentType: s.agent,
|
|
333
|
+
message: `rotated ${s.sessionId} → ${s.newSessionId}; replayed resume`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return s;
|
|
337
|
+
}
|
|
253
338
|
// --- the tick ---------------------------------------------------------------
|
|
254
339
|
/**
|
|
255
340
|
* Run ONE watchdog pass. Returns a structured outcome per live session; injects
|
|
@@ -268,6 +353,20 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
268
353
|
const smartDecider = opts.smartDecider ?? makeDefaultSmartDecider(opts.smartAgent ?? 'claude');
|
|
269
354
|
const openBlockFor = opts.openBlockFor ?? defaultOpenBlockFor;
|
|
270
355
|
const injectFn = opts.injectFn ?? injectIntoTerminal;
|
|
356
|
+
// Rotate seams (watchdog/rotate.ts). The config is read fresh per tick
|
|
357
|
+
// (readMeta is mtime-cached), so a `watchdog.rotate` flip is honored on the
|
|
358
|
+
// next pass. The default readiness probe (defaultTuiLiveFor) treats the
|
|
359
|
+
// new-session-id transcript as PRIMARY (a claude pick honors --session-id)
|
|
360
|
+
// with a CORRELATED fallback: a fresh active session counts only when it
|
|
361
|
+
// started after the rotate began AND shares the old session's cwd and
|
|
362
|
+
// machine — an unrelated fresh session on a busy box must never satisfy it
|
|
363
|
+
// (that would type the replay into a bare shell when the relaunch failed).
|
|
364
|
+
const rotateEnabled = opts.rotate ?? isWatchdogRotateEnabled();
|
|
365
|
+
const rotateGate = opts.rotateGate ?? defaultRotateGate;
|
|
366
|
+
const newSessionIdFor = opts.newSessionIdFor ?? (() => crypto.randomUUID());
|
|
367
|
+
const rotateReadinessMs = opts.rotateReadinessMs ?? DEFAULT_ROTATE_READINESS_MS;
|
|
368
|
+
const rotateKeyDelayMs = opts.rotateKeyDelayMs ?? 300;
|
|
369
|
+
const tuiLiveFor = opts.tuiLiveFor ?? defaultTuiLiveFor;
|
|
271
370
|
const sessions = opts.sessions ?? (await getActiveSessions());
|
|
272
371
|
// RUSH-2007 Layer C — reconcile per-session presence from this tick's active
|
|
273
372
|
// scan and persist it. Additive: it derives connect/disconnect from what this
|
|
@@ -291,6 +390,17 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
291
390
|
const outcomes = [];
|
|
292
391
|
const logEvents = [];
|
|
293
392
|
const viaLabel = (plan) => plan.via === 'inject' ? `inject (${plan.rail})` : plan.via;
|
|
393
|
+
// Rotate bookkeeping for this tick: ids the in-loop path already advanced
|
|
394
|
+
// (the post-loop sweep handles the rest — a session whose exit sequence
|
|
395
|
+
// killed it drops out of the active list, so only the sweep can finish it).
|
|
396
|
+
const advancedRotates = new Set();
|
|
397
|
+
const rotateDeps = {
|
|
398
|
+
dir, nowMs, sessions,
|
|
399
|
+
mayInject: opts.nudge === true,
|
|
400
|
+
injectFn, tuiLiveFor,
|
|
401
|
+
injectDryRun: opts.injectDryRun,
|
|
402
|
+
logEvents, flags,
|
|
403
|
+
};
|
|
294
404
|
for (const session of sessions) {
|
|
295
405
|
const policy = policyFor(session);
|
|
296
406
|
const base = {
|
|
@@ -349,6 +459,187 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
349
459
|
tailLines,
|
|
350
460
|
stalledForMs: status.stalledForMs,
|
|
351
461
|
};
|
|
462
|
+
// --- rotate path (watchdog/rotate.ts) -----------------------------------
|
|
463
|
+
// An in-flight rotate OWNS this session: advance the machine, never nudge.
|
|
464
|
+
// A stalled session whose tail shows a HARD LIMIT rotates in place instead
|
|
465
|
+
// of nudging — "Continue." cannot unspend a capped account.
|
|
466
|
+
if (rotateEnabled) {
|
|
467
|
+
const sid = session.sessionId;
|
|
468
|
+
const inflight = readRotateState(dir, sid);
|
|
469
|
+
if (inflight && isInflightPhase(inflight.phase)) {
|
|
470
|
+
const advanced = await advanceRotate(inflight, rotateDeps);
|
|
471
|
+
advancedRotates.add(sid);
|
|
472
|
+
outcomes.push({
|
|
473
|
+
...base, decision: 'rotate', rotatePhase: advanced.phase,
|
|
474
|
+
reason: rotateOutcomeReason(advanced),
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
// Failed-rotate retry cooldown: a terminal that already failed (e.g. its
|
|
479
|
+
// old TUI ignored the exit sequence) is not re-entered until the
|
|
480
|
+
// suppression recorded at the failure lapses.
|
|
481
|
+
if (inflight && inflight.phase === 'failed' && (inflight.suppressUntilMs ?? 0) > nowMs) {
|
|
482
|
+
outcomes.push({
|
|
483
|
+
...base, decision: 'skip', rotatePhase: 'failed',
|
|
484
|
+
reason: `rotate suppressed until ${new Date(inflight.suppressUntilMs).toISOString()} (failed-rotate cooldown)`,
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const verdict = classifyTailForRotate(tailLines, nowMs);
|
|
489
|
+
if (verdict.kind === 'rate_limited') {
|
|
490
|
+
// handsoff = detect + flag, but never rotate (mirrors the nudge path).
|
|
491
|
+
if (policy === 'handsoff') {
|
|
492
|
+
flags[sid] = {
|
|
493
|
+
reason: 'handsoff: rate-limited, would rotate in place but policy is hands-off',
|
|
494
|
+
host: session.host,
|
|
495
|
+
atMs: nowMs,
|
|
496
|
+
};
|
|
497
|
+
outcomes.push({
|
|
498
|
+
...base, decision: 'skip',
|
|
499
|
+
reason: 'handsoff: rate-limited — flagged, not rotated',
|
|
500
|
+
});
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// Dry tick (no --nudge): report what WOULD happen, touch nothing.
|
|
504
|
+
if (!opts.nudge) {
|
|
505
|
+
outcomes.push({
|
|
506
|
+
...base, decision: 'rotate',
|
|
507
|
+
reason: 'rate-limited — would rotate in place via `agents run auto` (dry — pass --nudge)',
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// First-party health gate — the SAME selection `agents run auto` would
|
|
512
|
+
// make. Zero healthy → ONE skip event per cooldown window, terminal
|
|
513
|
+
// untouched. Cooldown = earliestResetAcross (gate) → parsed tail reset
|
|
514
|
+
// → 30m default, whichever is known first. A gate THROW degrades to a
|
|
515
|
+
// skip for this session — it must never abort the whole tick (which
|
|
516
|
+
// would skip last-tick.json and every other session's decision).
|
|
517
|
+
let gate;
|
|
518
|
+
try {
|
|
519
|
+
gate = await rotateGate();
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
523
|
+
logEvents.push({
|
|
524
|
+
ts: nowMs, kind: 'error', terminalId: sid, agentType: candidate.agentType,
|
|
525
|
+
message: `rotate gate failed: ${msg}`,
|
|
526
|
+
});
|
|
527
|
+
outcomes.push({
|
|
528
|
+
...base, decision: 'skip',
|
|
529
|
+
reason: `rate-limited but the rotate health gate failed — skipped this tick: ${msg}`,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!gate.healthy) {
|
|
534
|
+
const cooldownMs = gate.resetsAtMs !== undefined ? Math.max(60_000, gate.resetsAtMs - nowMs)
|
|
535
|
+
: verdict.resetsAtMs !== undefined ? Math.max(60_000, verdict.resetsAtMs - nowMs)
|
|
536
|
+
: DEFAULT_ROTATE_SKIP_COOLDOWN_MS;
|
|
537
|
+
const suppressUntilMs = nowMs + cooldownMs;
|
|
538
|
+
if (shouldLogRotateSkip(dir, sid, nowMs)) {
|
|
539
|
+
recordRotateSkip(dir, sid, suppressUntilMs);
|
|
540
|
+
logEvents.push({
|
|
541
|
+
ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
|
|
542
|
+
message: `rotate skipped: no healthy harness — ${gate.detail}; suppressed until ${new Date(suppressUntilMs).toISOString()}`,
|
|
543
|
+
reason: gate.detail,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
outcomes.push({
|
|
547
|
+
...base, decision: 'skip',
|
|
548
|
+
reason: `rate-limited but no healthy account/harness to rotate into — terminal untouched (suppressed until ${new Date(suppressUntilMs).toISOString()})`,
|
|
549
|
+
});
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
// The safety gate is the same one the nudge path obeys: an exact
|
|
553
|
+
// addressable rail or an honest flag — never a guessed target.
|
|
554
|
+
const resolution = resolveInjectTargetForSession(session, { allowGhosttyFocus: opts.allowGhosttyFocus });
|
|
555
|
+
if (!resolution.addressable) {
|
|
556
|
+
flags[sid] = { reason: `rotate: ${resolution.reason}`, host: session.host, atMs: nowMs };
|
|
557
|
+
outcomes.push({
|
|
558
|
+
...base, decision: 'skip', addressable: false,
|
|
559
|
+
reason: `rate-limited but un-addressable — ${resolution.reason}`,
|
|
560
|
+
});
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
// Begin: persist exiting → inject the per-harness exit sequence →
|
|
564
|
+
// launching → inject `agents run auto` → awaiting-tui (bounded). cwd +
|
|
565
|
+
// machineHost are stored for the readiness fallback's correlation.
|
|
566
|
+
const newSessionId = newSessionIdFor();
|
|
567
|
+
let state = {
|
|
568
|
+
sessionId: sid,
|
|
569
|
+
newSessionId,
|
|
570
|
+
agent: session.kind,
|
|
571
|
+
phase: 'exiting',
|
|
572
|
+
target: resolution.target,
|
|
573
|
+
host: session.provenance?.transport === 'ssh' ? session.provenance.host : undefined,
|
|
574
|
+
cwd: session.cwd,
|
|
575
|
+
machineHost: session.provenance?.host,
|
|
576
|
+
startedAtMs: nowMs,
|
|
577
|
+
updatedAtMs: nowMs,
|
|
578
|
+
deadlineMs: nowMs + rotateReadinessMs,
|
|
579
|
+
};
|
|
580
|
+
writeRotateState(dir, state);
|
|
581
|
+
advancedRotates.add(sid);
|
|
582
|
+
let rotateFailed = null;
|
|
583
|
+
for (const key of exitSequenceFor(session.kind)) {
|
|
584
|
+
try {
|
|
585
|
+
const r = await injectFn(state.target, key, { dryRun: opts.injectDryRun, enter: false });
|
|
586
|
+
if (!r.ok) {
|
|
587
|
+
rotateFailed = `exit sequence inject failed: ${r.error ?? 'unknown error'}`;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
rotateFailed = `exit sequence inject threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
// A real TUI needs a beat between Esc and the interrupt pair.
|
|
596
|
+
if (rotateKeyDelayMs > 0 && !opts.injectDryRun) {
|
|
597
|
+
await new Promise((resolve) => setTimeout(resolve, rotateKeyDelayMs));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!rotateFailed) {
|
|
601
|
+
state = { ...state, phase: 'launching', updatedAtMs: nowMs };
|
|
602
|
+
writeRotateState(dir, state);
|
|
603
|
+
const launch = buildRotateLaunchCommand({ host: state.host, sessionId: newSessionId });
|
|
604
|
+
try {
|
|
605
|
+
const r = await injectFn(state.target, launch, { dryRun: opts.injectDryRun });
|
|
606
|
+
if (!r.ok)
|
|
607
|
+
rotateFailed = `launch inject failed: ${r.error ?? 'unknown error'}`;
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
rotateFailed = `launch inject threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (rotateFailed) {
|
|
614
|
+
state = {
|
|
615
|
+
...state, phase: 'failed', error: rotateFailed, updatedAtMs: nowMs,
|
|
616
|
+
suppressUntilMs: nowMs + DEFAULT_ROTATE_FAILED_COOLDOWN_MS,
|
|
617
|
+
};
|
|
618
|
+
writeRotateState(dir, state);
|
|
619
|
+
flags[sid] = { reason: `rotate failed: ${rotateFailed}`, host: session.host, atMs: nowMs };
|
|
620
|
+
logEvents.push({
|
|
621
|
+
ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
|
|
622
|
+
message: `rotate failed: ${sid} — ${rotateFailed}`,
|
|
623
|
+
});
|
|
624
|
+
outcomes.push({
|
|
625
|
+
...base, decision: 'rotate', rotatePhase: 'failed', addressable: true, rail: resolution.rail,
|
|
626
|
+
reason: `rotate failed: ${rotateFailed}`,
|
|
627
|
+
});
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
state = { ...state, phase: 'awaiting-tui', updatedAtMs: nowMs };
|
|
631
|
+
writeRotateState(dir, state);
|
|
632
|
+
logEvents.push({
|
|
633
|
+
ts: nowMs, kind: 'rotate', terminalId: sid, agentType: candidate.agentType,
|
|
634
|
+
message: `rotating ${sid} in place → agents run auto (new session ${newSessionId})`,
|
|
635
|
+
});
|
|
636
|
+
outcomes.push({
|
|
637
|
+
...base, decision: 'rotate', rotatePhase: 'awaiting-tui', addressable: true, rail: resolution.rail,
|
|
638
|
+
reason: `rotating in place → agents run auto (new session ${newSessionId})`,
|
|
639
|
+
});
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
352
643
|
// The brain. The cheap deterministic pre-filter resolves the obvious cases;
|
|
353
644
|
// parked-on-question and ambiguous stalls ESCALATE to the smart brain. `smart`
|
|
354
645
|
// forces every stalled candidate through the brain.
|
|
@@ -471,6 +762,17 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
471
762
|
});
|
|
472
763
|
}
|
|
473
764
|
}
|
|
765
|
+
// Advance in-flight rotates the in-loop path did NOT touch — critically the
|
|
766
|
+
// common case where the exit sequence killed the old harness, so the old
|
|
767
|
+
// session dropped out of the active-session list and only this sweep can
|
|
768
|
+
// finish the machine (readiness → replay, or deadline → fail + flag). Runs
|
|
769
|
+
// even when rotate was just disabled so a mid-flight machine is never
|
|
770
|
+
// stranded; a dry tick advances state but never injects (mayInject).
|
|
771
|
+
for (const inflight of listInflightRotates(dir)) {
|
|
772
|
+
if (advancedRotates.has(inflight.sessionId))
|
|
773
|
+
continue;
|
|
774
|
+
await advanceRotate(inflight, rotateDeps);
|
|
775
|
+
}
|
|
474
776
|
// Persist the cooldown ledger under a lock: fresh-read + merge this tick's
|
|
475
777
|
// updates + atomic write, so a concurrent tick's timestamps are never lost
|
|
476
778
|
// (the old unlocked read-at-start / write-at-end was a lost-update race).
|
|
@@ -497,6 +799,7 @@ export async function runWatchdogTick(opts = {}) {
|
|
|
497
799
|
nudged: outcomes.filter((o) => o.injected).length,
|
|
498
800
|
unaddressable: outcomes.filter((o) => o.addressable === false).length,
|
|
499
801
|
skipped: outcomes.filter((o) => o.decision === 'skip').length,
|
|
802
|
+
rotating: outcomes.filter((o) => o.decision === 'rotate').length,
|
|
500
803
|
};
|
|
501
804
|
const result = { atMs: nowMs, didNudge: opts.nudge === true, outcomes, counts, presence };
|
|
502
805
|
writeJsonFile(path.join(dir, 'last-tick.json'), result);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.3",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|