@cohortapp/agent-sdk 2.18.10 → 2.18.12
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/lib/session/revive.mjs +135 -0
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +70 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/revive.mjs — the decision to restart a front door that has
|
|
3
|
+
* stopped answering, made from OUTSIDE the process that is stuck.
|
|
4
|
+
*
|
|
5
|
+
* ── WHY THIS IS NOT IN THE SUPERVISOR ──────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The supervisor already watches its own session: since 2.18.7 it captures the
|
|
8
|
+
* pane, names the modal and answers one whose answer is free. That covers a
|
|
9
|
+
* session which wedges while the supervisor is healthy, and it is the right
|
|
10
|
+
* place for it.
|
|
11
|
+
*
|
|
12
|
+
* It cannot cover the case that actually held this fleet. James Kirkland's
|
|
13
|
+
* front door had been shut for 3.5 days and Isla Roselli's for 15, and their
|
|
14
|
+
* supervisors never relaunched once in that time: each was parked in its own
|
|
15
|
+
* launch probe, waiting on a mux session that would never report ready.
|
|
16
|
+
* Installing a newer SDK does not restart a process that is already stuck, so
|
|
17
|
+
* every fix shipped into the supervisor arrived somewhere it could not run.
|
|
18
|
+
*
|
|
19
|
+
* The hourly autoupdate job can restart it, and does since 2.18.8. An hour is
|
|
20
|
+
* a long time to be unable to answer anybody, and it only happens on the runs
|
|
21
|
+
* that get that far.
|
|
22
|
+
*
|
|
23
|
+
* The daemon is the honest home for this. It is a separate process, it is
|
|
24
|
+
* already alive on every seat that beats, it already reads the front-door
|
|
25
|
+
* state every poll to decide who owns the inbox, and it runs every thirty
|
|
26
|
+
* seconds. A seat whose door shuts is then measured in a minute rather than a
|
|
27
|
+
* day, by something the shut door cannot take down with it.
|
|
28
|
+
*
|
|
29
|
+
* Pure: the decision only. The daemon owns the kickstart.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Default silence before the daemon restarts the front door. */
|
|
33
|
+
export const DEFAULT_REVIVE_AFTER_MS = 10 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/** Default quiet period between attempts. */
|
|
36
|
+
export const DEFAULT_REVIVE_BACKOFF_MS = 15 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** How many restarts before the daemon stops and leaves it to a person. */
|
|
39
|
+
export const DEFAULT_REVIVE_MAX = 3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Should the daemon restart the front door right now?
|
|
43
|
+
*
|
|
44
|
+
* The caller supplies the front-door state it already reads for dispatch, the
|
|
45
|
+
* age of the session heartbeat, and what this daemon has already tried. Every
|
|
46
|
+
* bound is explicit because the failure mode of getting this wrong is a seat
|
|
47
|
+
* that restarts its own session every thirty seconds forever — which is worse
|
|
48
|
+
* than the shut door, and is the reason the ladder ends in "stop and say so"
|
|
49
|
+
* rather than in another attempt.
|
|
50
|
+
*
|
|
51
|
+
* @param {{frontDoor?:string, sessionLive?:boolean, silentMs?:number|null,
|
|
52
|
+
* attempts?:number, lastAttemptAt?:number|null, now:number,
|
|
53
|
+
* reviveAfterMs?:number, backoffMs?:number, maxAttempts?:number}} a
|
|
54
|
+
* @returns {{revive:boolean, reason:string}}
|
|
55
|
+
*/
|
|
56
|
+
export function shouldReviveFrontDoor(a) {
|
|
57
|
+
const x = a && typeof a === "object" ? a : {};
|
|
58
|
+
const now = Number(x.now);
|
|
59
|
+
const after = Number.isFinite(x.reviveAfterMs) && x.reviveAfterMs > 0 ? x.reviveAfterMs : DEFAULT_REVIVE_AFTER_MS;
|
|
60
|
+
const backoff = Number.isFinite(x.backoffMs) && x.backoffMs > 0 ? x.backoffMs : DEFAULT_REVIVE_BACKOFF_MS;
|
|
61
|
+
const max = Number.isFinite(x.maxAttempts) && x.maxAttempts >= 0 ? x.maxAttempts : DEFAULT_REVIVE_MAX;
|
|
62
|
+
|
|
63
|
+
// A seat whose lane is the daemon has no front door to revive: the daemon
|
|
64
|
+
// itself is answering, and restarting a session job it does not depend on
|
|
65
|
+
// would be a gratuitous interruption.
|
|
66
|
+
if (x.frontDoor !== "session") return { revive: false, reason: "front-door-daemon" };
|
|
67
|
+
|
|
68
|
+
// Working. This is the common case and it must be cheap and obviously safe:
|
|
69
|
+
// the heartbeat rides every tool use, so a session doing anything at all is
|
|
70
|
+
// fresh.
|
|
71
|
+
if (x.sessionLive === true) return { revive: false, reason: "answering" };
|
|
72
|
+
|
|
73
|
+
// Not live, but we cannot say for how long — a missing or unreadable
|
|
74
|
+
// heartbeat is not evidence of a wedge, and a daemon that restarts a session
|
|
75
|
+
// it knows nothing about is a daemon that fights its own launch.
|
|
76
|
+
// `Number(null)` is 0, and 0 is finite — so a MISSING heartbeat, which is
|
|
77
|
+
// exactly what `sessionLiveFromHeartbeat` reports as `ageMs: null`, would
|
|
78
|
+
// otherwise read as "silent for no time at all" and be reported as
|
|
79
|
+
// within-grace. Harmless at today's ten-minute grace and a lie at any grace
|
|
80
|
+
// of zero; either way the reason would name the wrong state.
|
|
81
|
+
const silentMs = x.silentMs === null || x.silentMs === undefined ? NaN : Number(x.silentMs);
|
|
82
|
+
if (!Number.isFinite(silentMs)) return { revive: false, reason: "silence-unknown" };
|
|
83
|
+
if (silentMs < after) return { revive: false, reason: "within-grace" };
|
|
84
|
+
|
|
85
|
+
const attempts = Number(x.attempts) || 0;
|
|
86
|
+
if (attempts >= max) return { revive: false, reason: "budget-spent" };
|
|
87
|
+
|
|
88
|
+
// One attempt, then wait. A front door takes time to come up, and a restart
|
|
89
|
+
// loop that re-fires before the new session can beat never lets it.
|
|
90
|
+
const last = Number(x.lastAttemptAt);
|
|
91
|
+
if (Number.isFinite(last) && now - last < backoff) return { revive: false, reason: "backing-off" };
|
|
92
|
+
|
|
93
|
+
return { revive: true, reason: `front door silent ${Math.round(silentMs / 1000)}s (attempt ${attempts + 1}/${max})` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The command that restarts the front-door job. Pure — the caller runs it.
|
|
98
|
+
*
|
|
99
|
+
* `kickstart -k` rather than a polite request: the session this exists for is
|
|
100
|
+
* one that cannot act on a request, and `maestro session restart` without
|
|
101
|
+
* `--force` asks the session to stand itself down at an idle moment it will
|
|
102
|
+
* never reach.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} label launchd label, e.g. "ai.maestro.james-session"
|
|
105
|
+
* @param {number} uid
|
|
106
|
+
* @returns {{file:string, args:string[]}}
|
|
107
|
+
*/
|
|
108
|
+
export function reviveCommand(label, uid) {
|
|
109
|
+
return { file: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${label}`] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This seat's front-door launchd label, read off disk rather than derived.
|
|
114
|
+
*
|
|
115
|
+
* The label is `ai.maestro.<first>-session`, but `<first>` is whatever the
|
|
116
|
+
* generator wrote — `~/Maya-ai` yields `Maya`, and case is preserved. Guessing
|
|
117
|
+
* it from a directory name is how a healthy seat gets accused of having no job
|
|
118
|
+
* (collect.mjs#sessionJobLabel carries the same warning). A readdir is a hard
|
|
119
|
+
* fact, and there is exactly one such plist on a seat.
|
|
120
|
+
*
|
|
121
|
+
* @param {(dir:string)=>string[]} readdir
|
|
122
|
+
* @param {string} homeDir
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
export function sessionJobLabelOnDisk(readdir, homeDir) {
|
|
126
|
+
if (!homeDir || typeof readdir !== "function") return null;
|
|
127
|
+
let names = [];
|
|
128
|
+
try { names = readdir(`${homeDir}/Library/LaunchAgents`) || []; } catch { return null; }
|
|
129
|
+
const hits = names
|
|
130
|
+
.filter((n) => /^ai\.maestro\..+-session\.plist$/.test(String(n)))
|
|
131
|
+
.map((n) => String(n).replace(/\.plist$/, ""));
|
|
132
|
+
// Two would mean two seats share a home directory, which is not a thing this
|
|
133
|
+
// may guess its way through.
|
|
134
|
+
return hits.length === 1 ? hits[0] : null;
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.12",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
// =============================================================================
|
|
18
18
|
|
|
19
19
|
import { resolve, join } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { execFile as _execFileCb } from "child_process";
|
|
22
|
+
import { promisify } from "util";
|
|
23
|
+
const execFileAsync = promisify(_execFileCb);
|
|
20
24
|
import { readdirSync, readFileSync, renameSync, mkdirSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
21
25
|
import { createRequire } from "module";
|
|
22
26
|
|
|
@@ -155,7 +159,8 @@ import { processOne } from "../../lib/execution/pipeline.mjs";
|
|
|
155
159
|
// of spawning `claude --print`; not live → today's dispatch, unchanged. The
|
|
156
160
|
// gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
|
|
157
161
|
// session claim that was neither replied nor done within 20 min.
|
|
158
|
-
import {
|
|
162
|
+
import { shouldReviveFrontDoor, reviveCommand, sessionJobLabelOnDisk } from "../../lib/session/revive.mjs";
|
|
163
|
+
import { makeFrontDoorGate, sessionLiveFromHeartbeat, readFrontDoorState } from "../../lib/session/frontdoor.mjs";
|
|
159
164
|
import { CADENCE_REGISTRY } from "./cadence-handlers.mjs";
|
|
160
165
|
import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
|
|
161
166
|
import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
|
|
@@ -269,6 +274,54 @@ async function poll() {
|
|
|
269
274
|
* on the 60s full poll. Returns the count of new items processed. Never throws.
|
|
270
275
|
*/
|
|
271
276
|
const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
|
|
277
|
+
|
|
278
|
+
// ── REVIVING A SHUT FRONT DOOR ───────────────────────────────────────────────
|
|
279
|
+
//
|
|
280
|
+
// The daemon already reads the front-door state every poll to decide who owns
|
|
281
|
+
// the inbox. It is therefore the one process that watches a session it does not
|
|
282
|
+
// live inside — and that is exactly what a wedged front door needs.
|
|
283
|
+
//
|
|
284
|
+
// The supervisor's own watchdog (2.18.7) cannot help when the supervisor is
|
|
285
|
+
// itself parked in its launch probe waiting on a session that will never report
|
|
286
|
+
// ready: James Kirkland sat that way for 3.5 days and Isla Roselli for 15,
|
|
287
|
+
// through several SDK upgrades, because installing new code does not restart a
|
|
288
|
+
// stuck process. The hourly autoupdate job restarts it (2.18.8) but only once
|
|
289
|
+
// an hour and only on the runs that reach that far. This closes it to a minute.
|
|
290
|
+
//
|
|
291
|
+
// State is per-process and deliberately not persisted: a daemon restart is
|
|
292
|
+
// itself a change of circumstances, and a fresh budget after one is the answer
|
|
293
|
+
// we want rather than a stale count carried across it.
|
|
294
|
+
/** How often the daemon asks whether the front door has gone quiet. */
|
|
295
|
+
const REVIVE_CHECK_INTERVAL_MS = 60 * 1000;
|
|
296
|
+
const _revive = { attempts: 0, lastAt: null };
|
|
297
|
+
async function reviveFrontDoorIfShut(state, deps = {}) {
|
|
298
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
299
|
+
const live = sessionLiveFromHeartbeat(state && state.heartbeat, { now });
|
|
300
|
+
const verdict = shouldReviveFrontDoor({
|
|
301
|
+
frontDoor: state && state.frontDoor,
|
|
302
|
+
sessionLive: state && state.sessionLive,
|
|
303
|
+
silentMs: live.ageMs,
|
|
304
|
+
attempts: _revive.attempts,
|
|
305
|
+
lastAttemptAt: _revive.lastAt,
|
|
306
|
+
now,
|
|
307
|
+
reviveAfterMs: Number(process.env.MAESTRO_SESSION_STALE_S || 0) * 1000 || undefined,
|
|
308
|
+
});
|
|
309
|
+
if (!verdict.revive) return verdict;
|
|
310
|
+
const label = sessionJobLabelOnDisk(readdirSync, homedir());
|
|
311
|
+
if (!label) return { revive: false, reason: "no-session-job" };
|
|
312
|
+
_revive.attempts += 1;
|
|
313
|
+
_revive.lastAt = now;
|
|
314
|
+
const cmd = reviveCommand(label, typeof process.getuid === "function" ? process.getuid() : 0);
|
|
315
|
+
console.error(`[daemon] front door shut — ${verdict.reason}; restarting ${label}`);
|
|
316
|
+
try {
|
|
317
|
+
await (deps.execFile || execFileAsync)(cmd.file, cmd.args);
|
|
318
|
+
console.error(`[daemon] ${label} restarted; a fresh session clears whatever the old one was sitting on`);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error(`[daemon] could not restart ${label}: ${err && err.message ? err.message : err} — this seat needs a person`);
|
|
321
|
+
}
|
|
322
|
+
return verdict;
|
|
323
|
+
}
|
|
324
|
+
|
|
272
325
|
async function pollService(svc) {
|
|
273
326
|
try {
|
|
274
327
|
// Front door: a live main session owns this service's inbox — leave it.
|
|
@@ -3096,6 +3149,22 @@ async function main() {
|
|
|
3096
3149
|
pollCohortFast().catch((err) => console.error("[daemon] cohort fast-poll error:", err.message));
|
|
3097
3150
|
}, COHORT_FAST_INTERVAL);
|
|
3098
3151
|
|
|
3152
|
+
// ── THE SHUT-DOOR CHECK GETS ITS OWN TICK ─────────────────────────────────
|
|
3153
|
+
//
|
|
3154
|
+
// It lived inside pollService for one release, which was the wrong place for
|
|
3155
|
+
// exactly the reason it exists: `pollCohortFast` guards itself with
|
|
3156
|
+
// `_cohortFastRunning`, so a poll that hangs starves every later call — and a
|
|
3157
|
+
// seat whose front door is wedged is precisely a seat where things hang. The
|
|
3158
|
+
// remedy must not share a lane with the thing it is rescuing.
|
|
3159
|
+
//
|
|
3160
|
+
// Its own interval, reading the front-door state directly, so nothing between
|
|
3161
|
+
// here and the kickstart can be blocked by the wedge.
|
|
3162
|
+
setInterval(() => {
|
|
3163
|
+
let state = null;
|
|
3164
|
+
try { state = readFrontDoorState(AGENT_REPO_DIR, { now: Date.now() }); } catch { state = null; }
|
|
3165
|
+
if (state) reviveFrontDoorIfShut(state).catch(() => {});
|
|
3166
|
+
}, REVIVE_CHECK_INTERVAL_MS);
|
|
3167
|
+
|
|
3099
3168
|
// Backlog sweep
|
|
3100
3169
|
setInterval(async () => {
|
|
3101
3170
|
try {
|