@ddtcorex/dsh-maestro-supervisor 0.7.7 → 0.7.9

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.
@@ -74,8 +74,21 @@ export async function pollHealth(opts = {}) {
74
74
  // (but otherwise fine) response was indistinguishable from a real crash, and
75
75
  // combined with a low down-threshold this caused a self-sustaining restart
76
76
  // loop (see Supervisor.downThreshold). 12s gives boot room without masking
77
- // a genuinely dead process for long.
78
- const fetchFn = opts.fetch ?? defaultFetch(opts.url ?? 'http://127.0.0.1:3080/', opts.timeoutMs ?? 12000);
77
+ // a genuinely dead process for long. 20s (Option A 2026-09-03) tolerates
78
+ // high load (loadavg 12) stalls; configurable via domains.supervisor.pollTimeoutMs.
79
+ // If opts.timeoutMs is not injected, read from supervisor config (maestro settings).
80
+ let effectiveTimeout = opts.timeoutMs;
81
+ if (effectiveTimeout === undefined) {
82
+ try {
83
+ const { readSupervisorConfig } = await import('./config.js');
84
+ const cfg = await readSupervisorConfig();
85
+ if (typeof cfg.pollTimeoutMs === 'number' && cfg.pollTimeoutMs > 0)
86
+ effectiveTimeout = cfg.pollTimeoutMs;
87
+ }
88
+ catch { }
89
+ effectiveTimeout ??= 20000;
90
+ }
91
+ const fetchFn = opts.fetch ?? defaultFetch(opts.url ?? 'http://127.0.0.1:3080/', effectiveTimeout);
79
92
  const psAliveFn = opts.psAlive ?? defaultPsAlive;
80
93
  const logTailFn = opts.logTail ?? defaultLogTail;
81
94
  let httpCode;
package/lib/plugin.d.ts CHANGED
@@ -73,6 +73,27 @@ export declare function runAutoResume(ctx: any, opts?: {
73
73
  logResume?: (entry: ResumeLogEntry) => void;
74
74
  config?: SupervisorPluginConfig;
75
75
  }): Promise<void>;
76
+ export interface RecoveredRoute {
77
+ provider: string;
78
+ model: string;
79
+ }
80
+ /**
81
+ * Recover the provider/model route a resumed agent needs for its
82
+ * `{{model}}` persona variable: handle seam first, raw session log as
83
+ * fallback. Returns `undefined` only for genuinely corrupt/routeless
84
+ * sessions, which the caller skips instead of resuming.
85
+ */
86
+ export declare function recoverAgentOptions(input: {
87
+ persistence: any;
88
+ sid: unknown;
89
+ sessionsRoot: string;
90
+ group: string;
91
+ sessionId: string;
92
+ logger?: {
93
+ warn?: (msg: string) => void;
94
+ };
95
+ id: string;
96
+ }): Promise<RecoveredRoute | undefined>;
76
97
  export declare function resumeInterrupted(ctx: any, ids: string[], deps?: {
77
98
  readIntent?: (id: string) => RestartIntent | undefined;
78
99
  consumeIntent?: (id: string) => void;
package/lib/plugin.js CHANGED
@@ -257,6 +257,98 @@ export async function runAutoResume(ctx, opts = {}) {
257
257
  catch { }
258
258
  }
259
259
  }
260
+ function routeFromEvents(events) {
261
+ if (!Array.isArray(events))
262
+ return undefined;
263
+ const context = [...events].reverse().find((event) => event?.type === 'request/context')?.data;
264
+ if (typeof context?.provider === 'string' && typeof context?.model === 'string') {
265
+ return { provider: context.provider, model: context.model };
266
+ }
267
+ return undefined;
268
+ }
269
+ /**
270
+ * Route recovery through the handle seam (`open(id, 'read')` + `read(0)`).
271
+ * A `read` handle never takes ownership, so this works while another handle
272
+ * or process holds `write`. Replaces the removed `persistence.load(id)`
273
+ * API (gone since DSH's handle-based persistence seam) — calling `load`
274
+ * now resolves to `undefined` and every resumed agent would lose its
275
+ * provider/model.
276
+ */
277
+ async function readRouteFromHandle(persistence, sid) {
278
+ const handle = await persistence?.open?.(sid, 'read');
279
+ if (handle === undefined || handle === null)
280
+ return undefined;
281
+ try {
282
+ return routeFromEvents(await handle.read(0));
283
+ }
284
+ finally {
285
+ try {
286
+ await handle.close?.();
287
+ }
288
+ catch { }
289
+ }
290
+ }
291
+ /**
292
+ * Raw-log fallback when the backend cannot open the session: stream the
293
+ * stored log for the first `request/context` line. `grep -m1` stops at the
294
+ * first match so huge logs cost one streamed pass, not a full decode.
295
+ */
296
+ async function readRouteFromRawLog(sessionsRoot, group, sessionId) {
297
+ const zstdPath = path.join(sessionsRoot, group, sessionId, 'session.jsonl.zstd');
298
+ const jsonlPath = path.join(sessionsRoot, group, sessionId, 'session.jsonl');
299
+ try {
300
+ if (fs.existsSync(zstdPath)) {
301
+ const { execSync } = await import('node:child_process');
302
+ const out = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null | grep -a -m1 '"type":"request/context"'`, { encoding: 'utf-8' });
303
+ const line = out.split('\n').find((l) => l.includes('request/context'));
304
+ if (line !== undefined) {
305
+ try {
306
+ return routeFromEvents([JSON.parse(line)]);
307
+ }
308
+ catch { }
309
+ }
310
+ return undefined;
311
+ }
312
+ if (fs.existsSync(jsonlPath)) {
313
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
314
+ for (const line of content.split('\n')) {
315
+ if (!line.includes('"request/context"'))
316
+ continue;
317
+ try {
318
+ const route = routeFromEvents([JSON.parse(line)]);
319
+ if (route !== undefined)
320
+ return route;
321
+ }
322
+ catch { }
323
+ }
324
+ }
325
+ }
326
+ catch { }
327
+ return undefined;
328
+ }
329
+ /**
330
+ * Recover the provider/model route a resumed agent needs for its
331
+ * `{{model}}` persona variable: handle seam first, raw session log as
332
+ * fallback. Returns `undefined` only for genuinely corrupt/routeless
333
+ * sessions, which the caller skips instead of resuming.
334
+ */
335
+ export async function recoverAgentOptions(input) {
336
+ try {
337
+ const viaHandle = await readRouteFromHandle(input.persistence, input.sid);
338
+ if (viaHandle !== undefined)
339
+ return viaHandle;
340
+ }
341
+ catch (e) {
342
+ input.logger?.warn?.(`[supervisor] auto-resume: could not recover route for ${input.id}: ${e?.message ?? String(e)}`);
343
+ }
344
+ try {
345
+ return await readRouteFromRawLog(input.sessionsRoot, input.group, input.sessionId);
346
+ }
347
+ catch (e) {
348
+ input.logger?.warn?.(`[supervisor] auto-resume: could not recover route for ${input.id}: ${e?.message ?? String(e)}`);
349
+ return undefined;
350
+ }
351
+ }
260
352
  export async function resumeInterrupted(ctx, ids, deps = {}) {
261
353
  const doReadIntent = deps.readIntent ?? readIntent;
262
354
  const doConsumeIntent = deps.consumeIntent ?? consumeIntent;
@@ -277,22 +369,22 @@ export async function resumeInterrupted(ctx, ids, deps = {}) {
277
369
  const { SessionId } = await import('@deepseek-ai/dsh-session').catch(() => ({ SessionId: (s) => s }));
278
370
  const sid = SessionId ? SessionId(sessionId) : sessionId;
279
371
  const persistence = ctx.get?.('sessionPersistence') ?? ctx.sessionPersistence;
280
- let agentOptions;
281
- try {
282
- const loaded = await persistence?.load?.(sessionId);
283
- const context = Array.isArray(loaded?.events)
284
- ? [...loaded.events].reverse().find((event) => event?.type === 'request/context')?.data
285
- : undefined;
286
- if (typeof context?.provider === 'string' && typeof context?.model === 'string') {
287
- agentOptions = { provider: context.provider, model: context.model };
288
- }
289
- }
290
- catch (e) {
291
- ctx.logger?.warn?.(`[supervisor] auto-resume: could not recover route for ${id}: ${e?.message ?? String(e)}`);
372
+ const sessionsRoot = deps.config?.sessionLogRoot ?? path.join(os.homedir(), '.dsh', 'sessions');
373
+ const group = id.slice(0, Math.max(0, id.length - sessionId.length - 1));
374
+ const agentOptions = await recoverAgentOptions({ persistence, sid, sessionsRoot, group, sessionId, logger: ctx.logger, id });
375
+ if (agentOptions === undefined) {
376
+ // Resuming without a provider/model builds an agent whose
377
+ // {{model}} persona variable has no value, so the very next turn
378
+ // fails with `prompt variable "{{model}}" has no value for this
379
+ // assembly (section "deployment:persona")`. Skip corrupt/routeless
380
+ // sessions instead of triggering a continue that can only fail.
381
+ ctx.logger?.warn?.(`[supervisor] auto-resume: skipping ${id} — no provider/model recovered (corrupt or routeless session)`);
382
+ doLog({ ts: Date.now(), sessionId, kind: 'resume-failed', error: 'missing provider/model: persistence has no request/context route — skipping corrupt/routeless session' });
383
+ continue;
292
384
  }
293
385
  const handle = await agents?.resume?.({
294
386
  resumeSessionId: sid,
295
- ...(agentOptions === undefined ? {} : { agentOptions }),
387
+ agentOptions,
296
388
  });
297
389
  agent = handle?.agent;
298
390
  if (agent !== undefined)
@@ -84,6 +84,8 @@ export declare class Supervisor {
84
84
  private getResumeWithinMs;
85
85
  private getEffectiveIntervalMs;
86
86
  private getEffectiveDownThreshold;
87
+ private getEffectiveDegradedThreshold;
88
+ private getEffectivePollTimeoutMs;
87
89
  private findInterruptedRecent;
88
90
  private collectGitDiff;
89
91
  private attemptAutoResume;
package/lib/supervisor.js CHANGED
@@ -211,7 +211,7 @@ export class Supervisor {
211
211
  return cfg.intervalMs;
212
212
  }
213
213
  catch { }
214
- return 3000;
214
+ return 5000;
215
215
  }
216
216
  async getEffectiveDownThreshold() {
217
217
  if (this.deps.downThreshold !== undefined)
@@ -222,8 +222,26 @@ export class Supervisor {
222
222
  return cfg.downThreshold;
223
223
  }
224
224
  catch { }
225
+ return 6;
226
+ }
227
+ async getEffectiveDegradedThreshold() {
228
+ try {
229
+ const cfg = await readSupervisorConfig();
230
+ if (typeof cfg.degradedThreshold === 'number' && cfg.degradedThreshold > 0)
231
+ return cfg.degradedThreshold;
232
+ }
233
+ catch { }
225
234
  return 5;
226
235
  }
236
+ async getEffectivePollTimeoutMs() {
237
+ try {
238
+ const cfg = await readSupervisorConfig();
239
+ if (typeof cfg.pollTimeoutMs === 'number' && cfg.pollTimeoutMs > 0)
240
+ return cfg.pollTimeoutMs;
241
+ }
242
+ catch { }
243
+ return 20000;
244
+ }
227
245
  async findInterruptedRecent(withinMs) {
228
246
  const ms = withinMs ?? this.getResumeWithinMs();
229
247
  // Prefer injected mock for testability
@@ -383,8 +401,8 @@ export class Supervisor {
383
401
  }
384
402
  }
385
403
  const now = this.deps.getTime ? this.deps.getTime() : Date.now();
386
- // degraded needs 3 consecutive (not downThreshold which tests set to 1) to avoid flapping
387
- const degradedThreshold = 3;
404
+ // degraded needs consecutive hits to avoid flapping Option A 2026-09-03: 3→5 (via config)
405
+ const degradedThreshold = await this.getEffectiveDegradedThreshold();
388
406
  this.consecutiveDegraded++;
389
407
  if (this.consecutiveDegraded < degradedThreshold) {
390
408
  if (now - this.lastDegradedNotify < 60000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",