@ddtcorex/dsh-maestro-supervisor 0.7.8 → 0.7.10
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/plugin.d.ts +21 -0
- package/lib/plugin.js +105 -13
- package/lib/restart-tool.d.ts +10 -0
- package/lib/restart-tool.js +35 -3
- package/package.json +1 -1
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
387
|
+
agentOptions,
|
|
296
388
|
});
|
|
297
389
|
agent = handle?.agent;
|
|
298
390
|
if (agent !== undefined)
|
package/lib/restart-tool.d.ts
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
* (out-of-band). This tool NEVER restarts the host in-tree.
|
|
14
14
|
*/
|
|
15
15
|
import { writeRestartRequest } from './restart-guards.js';
|
|
16
|
+
/**
|
|
17
|
+
* Copy a live profile tree for an isolated dry-boot. A naive recursive copy
|
|
18
|
+
* breaks `link:` installs: their node_modules entries are relative symlinks
|
|
19
|
+
* (e.g. `../../../shared/pkg`) that resolve against the copy location and
|
|
20
|
+
* dangle. Every symlink left dangling by the copy is rewritten to the
|
|
21
|
+
* absolute live target it pointed at, so the dry-boot loads the same code
|
|
22
|
+
* the live tree loads. Links already broken in the live tree are left alone
|
|
23
|
+
* (the dry-boot must stay faithful, not fix the live tree).
|
|
24
|
+
*/
|
|
25
|
+
export declare function copyProfileForDryBoot(srcDir: string, destDir: string): void;
|
|
16
26
|
/**
|
|
17
27
|
* Boot a copy of the live web profile on an isolated DSH_HOME and verify the
|
|
18
28
|
* plugin tree loads and serves. Returns ok + a one-line detail for the tool
|
package/lib/restart-tool.js
CHANGED
|
@@ -12,12 +12,44 @@
|
|
|
12
12
|
* callerSessionId) that the supervisor daemon owns and acts on
|
|
13
13
|
* (out-of-band). This tool NEVER restarts the host in-tree.
|
|
14
14
|
*/
|
|
15
|
-
import { join } from 'node:path';
|
|
16
|
-
import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, dirname, resolve } from 'node:path';
|
|
16
|
+
import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
17
17
|
import { tmpdir, homedir } from 'node:os';
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { writeRestartRequest } from './restart-guards.js';
|
|
21
|
+
/**
|
|
22
|
+
* Copy a live profile tree for an isolated dry-boot. A naive recursive copy
|
|
23
|
+
* breaks `link:` installs: their node_modules entries are relative symlinks
|
|
24
|
+
* (e.g. `../../../shared/pkg`) that resolve against the copy location and
|
|
25
|
+
* dangle. Every symlink left dangling by the copy is rewritten to the
|
|
26
|
+
* absolute live target it pointed at, so the dry-boot loads the same code
|
|
27
|
+
* the live tree loads. Links already broken in the live tree are left alone
|
|
28
|
+
* (the dry-boot must stay faithful, not fix the live tree).
|
|
29
|
+
*/
|
|
30
|
+
export function copyProfileForDryBoot(srcDir, destDir) {
|
|
31
|
+
cpSync(srcDir, destDir, { recursive: true, preserveTimestamps: true });
|
|
32
|
+
const repair = (dir, rel) => {
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const p = join(dir, name);
|
|
35
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
36
|
+
const st = lstatSync(p);
|
|
37
|
+
if (st.isSymbolicLink()) {
|
|
38
|
+
if (!existsSync(p)) {
|
|
39
|
+
const liveTarget = resolve(srcDir, dirname(r), readlinkSync(p));
|
|
40
|
+
if (existsSync(liveTarget)) {
|
|
41
|
+
unlinkSync(p);
|
|
42
|
+
symlinkSync(liveTarget, p);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (st.isDirectory()) {
|
|
47
|
+
repair(p, r);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
repair(destDir, '');
|
|
52
|
+
}
|
|
21
53
|
/**
|
|
22
54
|
* Boot a copy of the live web profile on an isolated DSH_HOME and verify the
|
|
23
55
|
* plugin tree loads and serves. Returns ok + a one-line detail for the tool
|
|
@@ -39,7 +71,7 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
|
|
|
39
71
|
const logs = [];
|
|
40
72
|
let child = null;
|
|
41
73
|
try {
|
|
42
|
-
|
|
74
|
+
copyProfileForDryBoot(liveProfile, join(tmpHome, 'profiles', 'web'));
|
|
43
75
|
const port = String(9000 + Math.floor(Math.random() * 1000));
|
|
44
76
|
const url = `http://127.0.0.1:${port}/`;
|
|
45
77
|
child = spawn('node', ['--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', '--no-open', '--port', port], {
|
package/package.json
CHANGED