@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0
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 +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/types/git.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* are synchronous child-process invocations scoped to the repo directory.
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
7
10
|
/**
|
|
8
11
|
* The repository's current HEAD, or null when the directory is not inside a
|
|
9
12
|
* git repository (or git itself is unavailable).
|
|
@@ -23,8 +26,27 @@ export function currentHead(repoDir) {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* Porcelain entries for every tracked or untracked working-tree change.
|
|
30
|
+
* `null` means git could not inspect the checkout; callers must fail closed.
|
|
31
|
+
*/
|
|
32
|
+
export function workingTreeChanges(repoDir) {
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' });
|
|
35
|
+
return out.split('\0').filter(entry => entry !== '');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether the checkout has no staged, unstaged, or untracked changes. */
|
|
42
|
+
export function isWorkingTreeClean(repoDir) {
|
|
43
|
+
const changes = workingTreeChanges(repoDir);
|
|
44
|
+
return changes !== null && changes.length === 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Record a clean HEAD directly, or commit the whole dirty working tree as an
|
|
48
|
+
* explicitly approved checkpoint snapshot. Dirty commits use a temporary git
|
|
49
|
+
* index: a hook/commit failure leaves the caller's real index untouched.
|
|
28
50
|
* @param repoDir - repository directory.
|
|
29
51
|
* @param message - checkpoint commit message.
|
|
30
52
|
* @param artifactPattern - staged paths matching this are reported as
|
|
@@ -32,22 +54,62 @@ export function currentHead(repoDir) {
|
|
|
32
54
|
* SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
|
|
33
55
|
* @returns the new HEAD sha, or a failure reason.
|
|
34
56
|
*/
|
|
35
|
-
export function commitCheckpoint(repoDir, message, artifactPattern) {
|
|
57
|
+
export function commitCheckpoint(repoDir, message, artifactPattern, includeDirty = false) {
|
|
58
|
+
let tempDir;
|
|
36
59
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
const changes = workingTreeChanges(repoDir);
|
|
61
|
+
if (changes === null)
|
|
62
|
+
return { ok: false, error: 'git checkpoint failed: working tree status is unavailable' };
|
|
63
|
+
if (changes.length > 0 && !includeDirty) {
|
|
64
|
+
const shown = changes.slice(0, 10).map(entry => ` ${entry}`).join('\n');
|
|
65
|
+
const more = changes.length > 10 ? `\n … (${changes.length - 10} more)` : '';
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `checkpoint refused: working tree has ${changes.length} change(s); review them, then rerun with --include-dirty to commit the complete snapshot:\n${shown}${more}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const previousHead = currentHead(repoDir);
|
|
72
|
+
if (previousHead === null)
|
|
73
|
+
return { ok: false, error: 'git checkpoint failed: current HEAD is unavailable' };
|
|
74
|
+
if (changes.length === 0)
|
|
75
|
+
return { ok: true, sha: previousHead, artifacts: [], createdCommit: false };
|
|
76
|
+
const indexPathRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
77
|
+
const indexPath = resolve(repoDir, indexPathRaw);
|
|
78
|
+
tempDir = mkdtempSync(join(tmpdir(), 'ankh-guard-index-'));
|
|
79
|
+
const tempIndex = join(tempDir, 'index');
|
|
80
|
+
copyFileSync(indexPath, tempIndex);
|
|
81
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
82
|
+
execFileSync('git', ['add', '-A'], { cwd: repoDir, env, stdio: 'pipe' });
|
|
83
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, env, encoding: 'utf8' });
|
|
39
84
|
const artifacts = artifactPattern === undefined
|
|
40
85
|
? []
|
|
41
86
|
: staged.split('\n').filter(file => artifactPattern.test(file));
|
|
42
|
-
execFileSync('git', ['commit', '
|
|
87
|
+
execFileSync('git', ['commit', '-m', message], { cwd: repoDir, env, stdio: 'pipe' });
|
|
43
88
|
const sha = currentHead(repoDir);
|
|
44
89
|
if (sha === null)
|
|
45
90
|
return { ok: false, error: 'checkpoint commit succeeded but HEAD became unreadable' };
|
|
46
|
-
|
|
91
|
+
try {
|
|
92
|
+
// The complete snapshot is now HEAD; make the real index match it. This
|
|
93
|
+
// is the successful-path equivalent of the old `git add -A && commit`,
|
|
94
|
+
// while the temporary index protected the failure path.
|
|
95
|
+
execFileSync('git', ['reset', '--mixed', 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
try {
|
|
99
|
+
execFileSync('git', ['update-ref', 'HEAD', previousHead, sha], { cwd: repoDir, stdio: 'pipe' });
|
|
100
|
+
}
|
|
101
|
+
catch { /* best effort */ }
|
|
102
|
+
return { ok: false, error: `checkpoint commit could not settle the real index: ${String(error)}` };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, sha, artifacts, createdCommit: true };
|
|
47
105
|
}
|
|
48
106
|
catch (error) {
|
|
49
107
|
return { ok: false, error: `git checkpoint failed: ${String(error)}` };
|
|
50
108
|
}
|
|
109
|
+
finally {
|
|
110
|
+
if (tempDir !== undefined)
|
|
111
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
51
113
|
}
|
|
52
114
|
/**
|
|
53
115
|
* Roll the checkout back to a checkpoint commit WITHOUT losing work: the
|
package/lib/types/index.d.ts
CHANGED
|
@@ -18,7 +18,53 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Context } from '@deepseek-ai/cordis';
|
|
20
20
|
import z from '@deepseek-ai/schemastery';
|
|
21
|
+
import { type RestartRequest, type RestartRequestResult } from './restart-request.ts';
|
|
21
22
|
import { type GuardState, type VerifyResult } from './state.ts';
|
|
23
|
+
/**
|
|
24
|
+
* The slice of a persisted session the preset derivation reads. Structural
|
|
25
|
+
* rather than the rc host's `PresetBearingSession`: the 0.1.2 host deleted
|
|
26
|
+
* that type together with `resolveSessionPreset`.
|
|
27
|
+
*/
|
|
28
|
+
interface PersistedPresetSource {
|
|
29
|
+
header: {
|
|
30
|
+
agentPreset?: string | null;
|
|
31
|
+
};
|
|
32
|
+
events: readonly {
|
|
33
|
+
type: string;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
}[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The two preset-derivation surfaces a host may carry: the rc line exports
|
|
39
|
+
* `resolveSessionPreset`; the 0.1.2 line replaced it (and the
|
|
40
|
+
* `PresetBearingSession` type) with the `agentPresetProjectionDefinition`
|
|
41
|
+
* unit. Probed per call, never from a version string.
|
|
42
|
+
*/
|
|
43
|
+
export interface PresetDerivationSurface {
|
|
44
|
+
agentPresetProjectionDefinition?: {
|
|
45
|
+
init(header: {
|
|
46
|
+
agentPreset?: string | null;
|
|
47
|
+
}): string | null;
|
|
48
|
+
apply(state: string | null, event: {
|
|
49
|
+
type: string;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}): string | null;
|
|
52
|
+
};
|
|
53
|
+
resolveSessionPreset?: (session: PersistedPresetSource) => string | undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Which preset a session actually runs, newest selection winning. The rc host
|
|
57
|
+
* exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
|
|
58
|
+
* replaced it with `agentPresetProjectionDefinition` (init from the header,
|
|
59
|
+
* fold `agent-preset/selected` events) — same semantics, so the derivation
|
|
60
|
+
* feature-detects either surface and never touches the version string. A host
|
|
61
|
+
* with neither yields undefined: the resume falls back to the deployment's
|
|
62
|
+
* default preset, the same outcome a preset-less session had before.
|
|
63
|
+
* @param host - the agent-presets module namespace, structurally probed.
|
|
64
|
+
* @param session - the session's header and event log.
|
|
65
|
+
* @returns the preset id, or `undefined` when the session names none.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveSessionPreset(host: PresetDerivationSurface, session: PersistedPresetSource): string | undefined;
|
|
22
68
|
/** Plugin configuration. */
|
|
23
69
|
export interface SelfRestartGuardConfig {
|
|
24
70
|
/** Credential freshness window in minutes (default 10). */
|
|
@@ -75,6 +121,7 @@ export type CheckpointResult = {
|
|
|
75
121
|
ok: true;
|
|
76
122
|
sha: string;
|
|
77
123
|
artifacts: string[];
|
|
124
|
+
createdCommit: boolean;
|
|
78
125
|
} | {
|
|
79
126
|
ok: false;
|
|
80
127
|
error: string;
|
|
@@ -90,11 +137,13 @@ export interface SelfRestartGuard {
|
|
|
90
137
|
*/
|
|
91
138
|
verify(): VerifyResult;
|
|
92
139
|
/**
|
|
93
|
-
* Record
|
|
140
|
+
* Record an externally proven green credential for the current clean HEAD.
|
|
141
|
+
* This synchronous service is a trusted orchestrator seam; agent/CLI flows
|
|
142
|
+
* must use `record --run -- PROGRAM` so the guard observes the exit status.
|
|
94
143
|
* @param scope - what passed, e.g. 'build+test'.
|
|
95
144
|
* @param options - optional command that produced the green state.
|
|
96
145
|
* @returns the persisted state including the new credential.
|
|
97
|
-
* @throws outside a git repository
|
|
146
|
+
* @throws outside a git repository or while the checkout is dirty.
|
|
98
147
|
*/
|
|
99
148
|
record(scope: string, options?: {
|
|
100
149
|
command?: string;
|
|
@@ -110,7 +159,9 @@ export interface SelfRestartGuard {
|
|
|
110
159
|
*/
|
|
111
160
|
status(): GuardState;
|
|
112
161
|
/**
|
|
113
|
-
*
|
|
162
|
+
* Remember the existing clean HEAD as a pre-batch checkpoint. Dirty trees
|
|
163
|
+
* are refused; the CLI's reviewed `--include-dirty` path is deliberately
|
|
164
|
+
* unavailable through this convenience service.
|
|
114
165
|
* @param message - batch description; defaults to 'batch snapshot'.
|
|
115
166
|
* @returns the checkpoint commit sha, or a failure reason.
|
|
116
167
|
*/
|
|
@@ -134,6 +185,17 @@ export interface SelfRestartGuard {
|
|
|
134
185
|
canary(options?: {
|
|
135
186
|
port?: number;
|
|
136
187
|
}): Promise<CanaryResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Trigger a guarded restart onto a new launch command (a UI-grade
|
|
190
|
+
* mode/profile switch). Dispatches on supervision: no live watchdog → the
|
|
191
|
+
* restart verb's stop→start→canary; supervised → reconfigure's transactional
|
|
192
|
+
* cutover (the only safe way to change the launch command under a live
|
|
193
|
+
* watchdog). The full gate chain applies — credential, composition
|
|
194
|
+
* preflight, marker/lock — and a refusal never stops the running instance.
|
|
195
|
+
* @param request - start: the successor launch command; profile: the dsh profile to preflight; initiator: the session id the restart report returns to (required — the UI caller knows the real session, never invent one).
|
|
196
|
+
* @returns the structured verdict; terminal hint text never crosses this seam.
|
|
197
|
+
*/
|
|
198
|
+
requestRestart(request: RestartRequest): Promise<RestartRequestResult>;
|
|
137
199
|
}
|
|
138
200
|
declare module '@deepseek-ai/cordis' {
|
|
139
201
|
interface Context {
|
|
@@ -150,4 +212,5 @@ export declare const inject: string[];
|
|
|
150
212
|
* @param config - validated plugin config.
|
|
151
213
|
*/
|
|
152
214
|
export declare function apply(ctx: Context, config: SelfRestartGuardConfig): void;
|
|
215
|
+
export {};
|
|
153
216
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/types/index.js
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { connect } from 'node:net';
|
|
3
3
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Namespace handle for runtime feature detection: the 0.1.2 host replaced
|
|
5
|
+
// the `resolveSessionPreset` free function (and the `PresetBearingSession`
|
|
6
|
+
// type) with the `agentPresetProjectionDefinition` unit, and a STATIC named
|
|
7
|
+
// import of a removed export is a SyntaxError at module load — exactly the
|
|
8
|
+
// failure this dual-host probing exists to survive. The derivation below
|
|
9
|
+
// reads both surfaces through one structural cast.
|
|
10
|
+
import * as agentPresetsHost from '@deepseek-ai/dsh-agent-presets';
|
|
6
11
|
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
7
12
|
import { dirname, join } from 'node:path';
|
|
8
13
|
import { fileURLToPath } from 'node:url';
|
|
9
14
|
import { resolveRepoDir, resolveStateDir, SRC_ARTIFACT_PATTERN } from "./defaults.js";
|
|
10
|
-
import { commitCheckpoint, currentHead, resetToCheckpoint } from "./git.js";
|
|
15
|
+
import { commitCheckpoint, currentHead, isWorkingTreeClean, resetToCheckpoint } from "./git.js";
|
|
16
|
+
import { listeningPortsForPid } from "./processes.js";
|
|
17
|
+
import { cutoverBlocksWake } from "./launch-spec.js";
|
|
11
18
|
import { stateFile } from "./state-files.js";
|
|
12
|
-
import {
|
|
19
|
+
import { registerBrowserHandoff } from "./browser-handoff.js";
|
|
20
|
+
import { requestRestart } from "./restart-request.js";
|
|
21
|
+
import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, isParkedOnUserInput, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
|
|
13
22
|
import { clearCredential, loadState, recordCredential, setCheckpoint, verifyCredential, } from "./state.js";
|
|
23
|
+
/**
|
|
24
|
+
* Which preset a session actually runs, newest selection winning. The rc host
|
|
25
|
+
* exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
|
|
26
|
+
* replaced it with `agentPresetProjectionDefinition` (init from the header,
|
|
27
|
+
* fold `agent-preset/selected` events) — same semantics, so the derivation
|
|
28
|
+
* feature-detects either surface and never touches the version string. A host
|
|
29
|
+
* with neither yields undefined: the resume falls back to the deployment's
|
|
30
|
+
* default preset, the same outcome a preset-less session had before.
|
|
31
|
+
* @param host - the agent-presets module namespace, structurally probed.
|
|
32
|
+
* @param session - the session's header and event log.
|
|
33
|
+
* @returns the preset id, or `undefined` when the session names none.
|
|
34
|
+
*/
|
|
35
|
+
export function deriveSessionPreset(host, session) {
|
|
36
|
+
if (host.agentPresetProjectionDefinition !== undefined) {
|
|
37
|
+
const projection = host.agentPresetProjectionDefinition;
|
|
38
|
+
let state = projection.init(session.header);
|
|
39
|
+
for (const event of session.events)
|
|
40
|
+
state = projection.apply(state, event);
|
|
41
|
+
return state ?? undefined;
|
|
42
|
+
}
|
|
43
|
+
return host.resolveSessionPreset?.(session);
|
|
44
|
+
}
|
|
14
45
|
export const Config = z.object({
|
|
15
46
|
maxAgeMinutes: z.natural().min(1).default(10),
|
|
16
47
|
stateDir: z.string().default(''),
|
|
@@ -56,7 +87,13 @@ function registerRestartSkill(ctx, stateDir) {
|
|
|
56
87
|
writeSkillRegistration(stateDir, { registered: false, reason: 'shipped SKILL.md malformed', at: Date.now() });
|
|
57
88
|
return;
|
|
58
89
|
}
|
|
59
|
-
ctx.effect(() => skills.register({
|
|
90
|
+
ctx.effect(() => skills.register({
|
|
91
|
+
name,
|
|
92
|
+
description,
|
|
93
|
+
content,
|
|
94
|
+
source: 'runtime',
|
|
95
|
+
provider: 'ankh-guard',
|
|
96
|
+
}));
|
|
60
97
|
writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
|
|
61
98
|
}
|
|
62
99
|
catch (error) {
|
|
@@ -119,6 +156,7 @@ export function apply(ctx, config) {
|
|
|
119
156
|
// (default-on!) with no warning — a misconfiguration failing silent.
|
|
120
157
|
const followupReport = reportMode === 'followup';
|
|
121
158
|
registerRestartSkill(ctx, stateDir);
|
|
159
|
+
registerBrowserHandoff(ctx, stateDir);
|
|
122
160
|
if (followupReport || resumeInterrupted) {
|
|
123
161
|
const pluginMessage = (text) => createUserMessage({
|
|
124
162
|
content: [{ type: 'text', text }],
|
|
@@ -129,6 +167,33 @@ export function apply(ctx, config) {
|
|
|
129
167
|
const pendingContinue = new Map();
|
|
130
168
|
let disposed = false;
|
|
131
169
|
ctx.effect(() => () => { disposed = true; });
|
|
170
|
+
// A turn parked on user input (an open ask_user_question call, or an
|
|
171
|
+
// undecided approval) is not interrupted WORK — the card persists in the
|
|
172
|
+
// log and the user answers whenever. Auto-continuing it replays the
|
|
173
|
+
// question and burns a turn for nothing (observed on prod 3080: sessions
|
|
174
|
+
// parked on question cards were woken on every restart of an upgrade
|
|
175
|
+
// day). The probe reads the session's repaired log tail; memoized per
|
|
176
|
+
// boot; a probe failure fails open to the pre-existing behavior.
|
|
177
|
+
const parkedMemo = new Map();
|
|
178
|
+
const checkParked = (id) => {
|
|
179
|
+
let probe = parkedMemo.get(id);
|
|
180
|
+
if (probe === undefined) {
|
|
181
|
+
probe = (async () => {
|
|
182
|
+
try {
|
|
183
|
+
const persistence = ctx.get('sessionPersistence');
|
|
184
|
+
if (persistence === undefined)
|
|
185
|
+
return false;
|
|
186
|
+
const { events } = await persistence.inspect(id);
|
|
187
|
+
return isParkedOnUserInput(events);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
parkedMemo.set(id, probe);
|
|
194
|
+
}
|
|
195
|
+
return probe;
|
|
196
|
+
};
|
|
132
197
|
const claim = (agent, record) => {
|
|
133
198
|
const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
|
|
134
199
|
const text = restartContextText(record, canaryPending);
|
|
@@ -142,33 +207,65 @@ export function apply(ctx, config) {
|
|
|
142
207
|
};
|
|
143
208
|
// The single delivery path for every resume trigger (this plugin's pass,
|
|
144
209
|
// the UI, the schedule system): an interrupted session gets exactly one
|
|
145
|
-
// "continue" injection
|
|
146
|
-
//
|
|
210
|
+
// "continue" injection — unless its interrupted turn was parked on user
|
|
211
|
+
// input, in which case the card in the log is the continuation and no
|
|
212
|
+
// injection fires; the restart's initiator gets the report — merged into
|
|
213
|
+
// one message when it is both. The map makes repeat calls no-ops.
|
|
147
214
|
const deliver = (agent) => {
|
|
215
|
+
// Transport can be up before the watchdog has exchanged the launch URL,
|
|
216
|
+
// handed it to the browser, or run the canary. Do not let that early
|
|
217
|
+
// mount wake a session; the release poll below retries live roots once
|
|
218
|
+
// the durable receipt reaches a terminal phase.
|
|
219
|
+
if (cutoverBlocksWake(stateDir))
|
|
220
|
+
return;
|
|
148
221
|
const id = agent.id;
|
|
149
222
|
const exitAt = pendingContinue.get(id);
|
|
150
223
|
const record = followupReport ? pendingRestartRecord(stateDir) : null;
|
|
151
|
-
|
|
152
|
-
&& (record.initiator === undefined || id === record.initiator)) {
|
|
153
|
-
// The initiator was itself interrupted by its own restart: one
|
|
154
|
-
// combined turn continues the work AND reports the outcome — two
|
|
155
|
-
// separate injections would run two near-duplicate turns.
|
|
156
|
-
const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
|
|
157
|
-
const text = continueAndReportText(record, canaryPending);
|
|
158
|
-
if (text !== '') {
|
|
159
|
-
agent.followup(pluginMessage(text));
|
|
160
|
-
pendingContinue.delete(id);
|
|
161
|
-
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
// A record with nothing to report yet (no exitAt/error) must not
|
|
165
|
-
// swallow the continue — fall through to the continue-only path.
|
|
166
|
-
}
|
|
224
|
+
const owesReport = record !== null && (record.initiator === undefined || id === record.initiator);
|
|
167
225
|
if (exitAt !== undefined) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
226
|
+
void (async () => {
|
|
227
|
+
const parked = await checkParked(id);
|
|
228
|
+
if (disposed || pendingContinue.get(id) !== exitAt)
|
|
229
|
+
return;
|
|
230
|
+
// Delete before injecting: two resume triggers racing the memoized
|
|
231
|
+
// probe must not double-inject; a throwing followup re-arms the
|
|
232
|
+
// session for its next creation (same rule as claim()).
|
|
233
|
+
pendingContinue.delete(id);
|
|
234
|
+
if (parked) {
|
|
235
|
+
// Parked on user input: the card IS the continuation. An owed
|
|
236
|
+
// report still lands (report-only text, not the merged one).
|
|
237
|
+
if (owesReport && record !== null)
|
|
238
|
+
claim(agent, record);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (owesReport && record !== null) {
|
|
242
|
+
// The initiator was itself interrupted by its own restart: one
|
|
243
|
+
// combined turn continues the work AND reports the outcome — two
|
|
244
|
+
// separate injections would run two near-duplicate turns.
|
|
245
|
+
const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
|
|
246
|
+
const text = continueAndReportText(record, canaryPending);
|
|
247
|
+
if (text !== '') {
|
|
248
|
+
try {
|
|
249
|
+
agent.followup(pluginMessage(text));
|
|
250
|
+
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
pendingContinue.set(id, exitAt);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// A record with nothing to report yet (no exitAt/error) must not
|
|
259
|
+
// swallow the continue — fall through to the continue-only path.
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
agent.followup(pluginMessage(continueInterruptedText(exitAt)));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
pendingContinue.set(id, exitAt);
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
return;
|
|
172
269
|
}
|
|
173
270
|
if (record === null)
|
|
174
271
|
return;
|
|
@@ -232,7 +329,7 @@ export function apply(ctx, config) {
|
|
|
232
329
|
const persistence = ctx.get('sessionPersistence');
|
|
233
330
|
if (presets !== undefined && persistence !== undefined) {
|
|
234
331
|
const inspected = await persistence.inspect(id);
|
|
235
|
-
const presetId =
|
|
332
|
+
const presetId = deriveSessionPreset(agentPresetsHost, { header: inspected.meta, events: inspected.events });
|
|
236
333
|
setup = async (agentCtx) => { await presets.mount(agentCtx, (await presets.resolve(presetId)).id); };
|
|
237
334
|
}
|
|
238
335
|
return { resumeSessionId: id, agentOptions, ...(setup === undefined ? {} : { setup }) };
|
|
@@ -260,6 +357,11 @@ export function apply(ctx, config) {
|
|
|
260
357
|
// happens through `deliver` below, from the `agent/created` listener or
|
|
261
358
|
// the live branch here.
|
|
262
359
|
const resumePass = async () => {
|
|
360
|
+
while (!disposed && cutoverBlocksWake(stateDir)) {
|
|
361
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
362
|
+
}
|
|
363
|
+
if (disposed)
|
|
364
|
+
return;
|
|
263
365
|
const snapshot = readInterruptedSnapshot(stateDir);
|
|
264
366
|
if (snapshot === null)
|
|
265
367
|
return;
|
|
@@ -269,6 +371,13 @@ export function apply(ctx, config) {
|
|
|
269
371
|
for (const id of [...new Set([...snapshot.resume, ...snapshot.interrupted])]) {
|
|
270
372
|
if (disposed)
|
|
271
373
|
return;
|
|
374
|
+
// Parked on user input: the card in the log is the continuation —
|
|
375
|
+
// do not recreate the agent at all (its creation would fire the
|
|
376
|
+
// delivery path; deliver() also filters, belt and suspenders).
|
|
377
|
+
if (await checkParked(id)) {
|
|
378
|
+
pendingContinue.delete(id);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
272
381
|
const live = ctx.agents.list().find(agent => agent.id === id);
|
|
273
382
|
if (live !== undefined) {
|
|
274
383
|
// Already live: its `agent/created` may have predated this plugin's
|
|
@@ -297,6 +406,19 @@ export function apply(ctx, config) {
|
|
|
297
406
|
const timer = setTimeout(() => { void resumePass(); }, resumeDelayMs);
|
|
298
407
|
ctx.effect(() => () => { clearTimeout(timer); });
|
|
299
408
|
}
|
|
409
|
+
// The browser handoff can create an agent while the receipt still says
|
|
410
|
+
// canary-pending. agent/created is one-shot, so retry already-live roots
|
|
411
|
+
// after release; acknowledgement and pendingContinue preserve exact-once.
|
|
412
|
+
if (cutoverBlocksWake(stateDir)) {
|
|
413
|
+
const releaseTimer = setInterval(() => {
|
|
414
|
+
if (disposed || cutoverBlocksWake(stateDir))
|
|
415
|
+
return;
|
|
416
|
+
clearInterval(releaseTimer);
|
|
417
|
+
for (const agent of ctx.agents.roots())
|
|
418
|
+
deliver(agent);
|
|
419
|
+
}, 250);
|
|
420
|
+
ctx.effect(() => () => { clearInterval(releaseTimer); });
|
|
421
|
+
}
|
|
300
422
|
ctx.on('agent/created', ({ agent }) => {
|
|
301
423
|
if (!ctx.agents.roots().includes(agent))
|
|
302
424
|
return;
|
|
@@ -313,6 +435,8 @@ export function apply(ctx, config) {
|
|
|
313
435
|
const record = pendingRestartRecord(stateDir);
|
|
314
436
|
if (record === null)
|
|
315
437
|
return decision;
|
|
438
|
+
if (cutoverBlocksWake(stateDir))
|
|
439
|
+
return decision;
|
|
316
440
|
const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
|
|
317
441
|
const text = restartContextText(record, canaryPending);
|
|
318
442
|
if (text === '')
|
|
@@ -334,11 +458,13 @@ export function apply(ctx, config) {
|
|
|
334
458
|
}, { prepend: true });
|
|
335
459
|
}
|
|
336
460
|
const service = {
|
|
337
|
-
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
|
|
461
|
+
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
|
|
338
462
|
record: (scope, options) => {
|
|
339
463
|
const head = currentHead(repoDir);
|
|
340
464
|
if (head === null)
|
|
341
465
|
throw new Error('ankh-guard: cannot record a credential outside a git repository');
|
|
466
|
+
if (!isWorkingTreeClean(repoDir))
|
|
467
|
+
throw new Error('ankh-guard: cannot record a credential while the working tree is dirty');
|
|
342
468
|
return recordCredential(stateDir, { scope, revision: head, command: options?.command ?? '' }, Date.now());
|
|
343
469
|
},
|
|
344
470
|
clear: () => clearCredential(stateDir, Date.now()),
|
|
@@ -351,6 +477,7 @@ export function apply(ctx, config) {
|
|
|
351
477
|
return result;
|
|
352
478
|
},
|
|
353
479
|
reset: sha => resetToCheckpoint(repoDir, sha),
|
|
480
|
+
requestRestart: request => requestRestart(request, { stateDir, repoDir }),
|
|
354
481
|
canary: async (options) => {
|
|
355
482
|
const checks = [];
|
|
356
483
|
const verdict = service.verify();
|
|
@@ -378,16 +505,7 @@ export function apply(ctx, config) {
|
|
|
378
505
|
if (key.startsWith('DSH_') && value !== undefined)
|
|
379
506
|
env[key] = value;
|
|
380
507
|
}
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
const out = execFileSync('lsof', ['-a', '-p', String(process.pid), '-iTCP', '-sTCP:LISTEN', '-P'], { encoding: 'utf8', stdio: 'pipe' });
|
|
384
|
-
const match = /:(\d+) \(LISTEN\)/.exec(out);
|
|
385
|
-
if (match !== null)
|
|
386
|
-
port = Number(match[1]);
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
// lsof unavailable or nothing listening yet — the record still helps.
|
|
390
|
-
}
|
|
508
|
+
const port = listeningPortsForPid(process.pid)[0];
|
|
391
509
|
writeInstanceLaunch(stateDir, {
|
|
392
510
|
command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
|
|
393
511
|
source: 'instance',
|