@gajae-code/coding-agent 0.11.4 → 0.11.6
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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +388 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +1447 -852
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawn as childProcessSpawn } from "node:child_process";
|
|
2
2
|
import * as crypto from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
6
|
+
import * as native from "@gajae-code/natives";
|
|
7
|
+
import { Process } from "@gajae-code/natives";
|
|
5
8
|
import type { Settings } from "../../config/settings";
|
|
6
9
|
import type {
|
|
7
10
|
BuiltInDaemonController,
|
|
@@ -18,6 +21,23 @@ import { getNotificationConfig, isDiscordConfigured, isSlackConfigured } from ".
|
|
|
18
21
|
export type ChatDaemonKind = "discord" | "slack";
|
|
19
22
|
export type ChatDaemonAction = "stop" | "reload";
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Operational generations of the Discord/Slack daemon lifecycle contracts.
|
|
26
|
+
* These are intentionally separate from per-session endpoint generations.
|
|
27
|
+
* Generation 6 carries the retained managed filesystem authority boundary.
|
|
28
|
+
* Generation 7 restores macOS daemon signaling (kill(2) with a start-time
|
|
29
|
+
* incarnation recheck) so a live/hung owner can be replaced without an external
|
|
30
|
+
* `kill -9`.
|
|
31
|
+
*/
|
|
32
|
+
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
33
|
+
discord: 7,
|
|
34
|
+
slack: 7,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
38
|
+
return CHAT_DAEMON_GENERATIONS[kind];
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export interface ChatDaemonState {
|
|
22
42
|
version: 1;
|
|
23
43
|
kind: ChatDaemonKind;
|
|
@@ -28,9 +48,111 @@ export interface ChatDaemonState {
|
|
|
28
48
|
startedAt: number;
|
|
29
49
|
heartbeatAt: number;
|
|
30
50
|
transportHealthy: boolean;
|
|
51
|
+
generation: number;
|
|
31
52
|
stoppedAt?: number;
|
|
32
53
|
}
|
|
33
54
|
|
|
55
|
+
/**
|
|
56
|
+
* State files are untrusted persisted input. A record must be completely valid
|
|
57
|
+
* before its PID can be treated as an owner, stopped, or safe to replace.
|
|
58
|
+
*/
|
|
59
|
+
/** A legacy owner is recognized only when the sole missing field is generation. */
|
|
60
|
+
export function isRecognizedLegacyGeneration(value: unknown): value is undefined {
|
|
61
|
+
return value === undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasProcessIncarnationAuthority(incarnation: unknown): incarnation is string {
|
|
65
|
+
return typeof incarnation === "string" && isProcessIncarnation(incarnation);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasSafeChatDaemonOwnerShape(
|
|
69
|
+
value: unknown,
|
|
70
|
+
): value is Omit<ChatDaemonState, "generation"> & { generation?: unknown } {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
72
|
+
const state = value as Record<string, unknown>;
|
|
73
|
+
return (
|
|
74
|
+
state.version === 1 &&
|
|
75
|
+
(state.kind === "discord" || state.kind === "slack") &&
|
|
76
|
+
typeof state.pid === "number" &&
|
|
77
|
+
Number.isSafeInteger(state.pid) &&
|
|
78
|
+
state.pid > 0 &&
|
|
79
|
+
typeof state.ownerId === "string" &&
|
|
80
|
+
state.ownerId.length > 0 &&
|
|
81
|
+
typeof state.identity === "string" &&
|
|
82
|
+
state.identity.length > 0 &&
|
|
83
|
+
hasProcessIncarnationAuthority(state.incarnation) &&
|
|
84
|
+
typeof state.startedAt === "number" &&
|
|
85
|
+
Number.isFinite(state.startedAt) &&
|
|
86
|
+
typeof state.heartbeatAt === "number" &&
|
|
87
|
+
Number.isFinite(state.heartbeatAt) &&
|
|
88
|
+
typeof state.transportHealthy === "boolean" &&
|
|
89
|
+
(state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasSafeChatDaemonStateShape(value: unknown): value is ChatDaemonState {
|
|
94
|
+
if (!hasSafeChatDaemonOwnerShape(value)) return false;
|
|
95
|
+
const state = value as Record<string, unknown>;
|
|
96
|
+
return typeof state.generation === "number" && Number.isSafeInteger(state.generation) && state.generation >= 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Versions before immutable process provenance persisted this single sentinel.
|
|
101
|
+
* Recover it only after proving its recorded PID is dead; every other malformed
|
|
102
|
+
* record remains fail-closed.
|
|
103
|
+
*/
|
|
104
|
+
function isExactPreUpgradeUnavailableChatDaemonState(
|
|
105
|
+
value: unknown,
|
|
106
|
+
): value is Omit<ChatDaemonState, "generation" | "incarnation"> & { incarnation: "unavailable" } {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
108
|
+
const state = value as Record<string, unknown>;
|
|
109
|
+
const keys = Object.keys(state);
|
|
110
|
+
if (
|
|
111
|
+
keys.some(
|
|
112
|
+
key =>
|
|
113
|
+
key !== "version" &&
|
|
114
|
+
key !== "kind" &&
|
|
115
|
+
key !== "pid" &&
|
|
116
|
+
key !== "ownerId" &&
|
|
117
|
+
key !== "identity" &&
|
|
118
|
+
key !== "incarnation" &&
|
|
119
|
+
key !== "startedAt" &&
|
|
120
|
+
key !== "heartbeatAt" &&
|
|
121
|
+
key !== "transportHealthy" &&
|
|
122
|
+
key !== "stoppedAt",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return false;
|
|
126
|
+
return (
|
|
127
|
+
state.version === 1 &&
|
|
128
|
+
(state.kind === "discord" || state.kind === "slack") &&
|
|
129
|
+
typeof state.pid === "number" &&
|
|
130
|
+
Number.isSafeInteger(state.pid) &&
|
|
131
|
+
state.pid > 0 &&
|
|
132
|
+
typeof state.ownerId === "string" &&
|
|
133
|
+
state.ownerId.length > 0 &&
|
|
134
|
+
typeof state.identity === "string" &&
|
|
135
|
+
state.identity.length > 0 &&
|
|
136
|
+
state.incarnation === "unavailable" &&
|
|
137
|
+
typeof state.startedAt === "number" &&
|
|
138
|
+
Number.isFinite(state.startedAt) &&
|
|
139
|
+
typeof state.heartbeatAt === "number" &&
|
|
140
|
+
Number.isFinite(state.heartbeatAt) &&
|
|
141
|
+
typeof state.transportHealthy === "boolean" &&
|
|
142
|
+
(state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasChatDaemonStatePid(value: unknown): value is { pid: number } {
|
|
147
|
+
return (
|
|
148
|
+
!!value &&
|
|
149
|
+
typeof value === "object" &&
|
|
150
|
+
typeof (value as { pid?: unknown }).pid === "number" &&
|
|
151
|
+
Number.isSafeInteger((value as { pid: number }).pid) &&
|
|
152
|
+
(value as { pid: number }).pid > 0
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
34
156
|
export interface ChatDaemonControlRequest {
|
|
35
157
|
version: 1;
|
|
36
158
|
requestId: string;
|
|
@@ -41,21 +163,61 @@ export interface ChatDaemonControlRequest {
|
|
|
41
163
|
incarnation: string;
|
|
42
164
|
}
|
|
43
165
|
|
|
166
|
+
export interface ChatDaemonProcessReference {
|
|
167
|
+
incarnation: string;
|
|
168
|
+
signalRoot(signal: NodeJS.Signals): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function defaultProcessReference(pid: number, platform = os.platform()): ChatDaemonProcessReference | undefined {
|
|
172
|
+
try {
|
|
173
|
+
const processRef = Process.fromPid(pid);
|
|
174
|
+
if (!processRef || !hasProcessIncarnationAuthority(processRef.incarnation)) return undefined;
|
|
175
|
+
const incarnation = processRef.incarnation;
|
|
176
|
+
return {
|
|
177
|
+
incarnation,
|
|
178
|
+
signalRoot: signal => {
|
|
179
|
+
const nativeSignal = os.constants.signals[signal];
|
|
180
|
+
if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
|
|
181
|
+
// macOS exposes no pidfd and the native signal_root is a no-op there, so
|
|
182
|
+
// the daemon control plane previously had NO way to signal a live owner —
|
|
183
|
+
// every stop/reload of a live/hung daemon refused, and only an external
|
|
184
|
+
// `kill -9` could recover it. Signal by numeric PID via kill(2), but
|
|
185
|
+
// re-read the immutable start-time incarnation immediately beforehand so a
|
|
186
|
+
// PID that exited and was reused since capture is never signaled.
|
|
187
|
+
if (platform === "darwin") {
|
|
188
|
+
const current = Process.fromPid(pid) as { incarnation?: unknown } | null;
|
|
189
|
+
if (!current || current.incarnation !== incarnation) throw new Error("Pinned process is already gone");
|
|
190
|
+
process.kill(pid, signal);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
|
|
194
|
+
if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
44
202
|
export interface ChatDaemonControlDeps {
|
|
45
203
|
pidAlive?: (pid: number) => boolean;
|
|
46
|
-
|
|
204
|
+
processReference?: (pid: number) => ChatDaemonProcessReference | undefined;
|
|
47
205
|
spawn?: (command: string, args: string[], opts: { detached: boolean; stdio: "ignore" }) => { unref?: () => void };
|
|
48
206
|
execPath?: string;
|
|
49
207
|
ownerPid?: number;
|
|
50
208
|
randomId?: () => string;
|
|
51
209
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
210
|
+
/** Test seam for platform-specific default stable-process authority. */
|
|
211
|
+
platform?: NodeJS.Platform;
|
|
52
212
|
sleep?: (ms: number) => Promise<void>;
|
|
213
|
+
spawnReadyTimeoutMs?: number;
|
|
53
214
|
}
|
|
54
215
|
|
|
55
216
|
const HEARTBEAT_TTL_MS = 20_000;
|
|
56
217
|
const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
57
218
|
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
|
58
|
-
|
|
219
|
+
/** Covers Discord READY plus its first 5-second heartbeat; tests inject a smaller timeout. */
|
|
220
|
+
const DEFAULT_SPAWN_READY_TIMEOUT_MS = 8_000;
|
|
59
221
|
|
|
60
222
|
interface ChatDaemonOwnerLock {
|
|
61
223
|
pid: number;
|
|
@@ -63,6 +225,15 @@ interface ChatDaemonOwnerLock {
|
|
|
63
225
|
createdAt: number;
|
|
64
226
|
}
|
|
65
227
|
|
|
228
|
+
interface ChatDaemonOwnerLockLease {
|
|
229
|
+
content: string;
|
|
230
|
+
dev: bigint;
|
|
231
|
+
ino: bigint;
|
|
232
|
+
size: bigint;
|
|
233
|
+
mtimeNs: bigint;
|
|
234
|
+
sha256: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
66
237
|
interface ChatDaemonOwnershipProbe {
|
|
67
238
|
pidAlive(pid: number): boolean;
|
|
68
239
|
pidIncarnation(pid: number): string | undefined;
|
|
@@ -117,12 +288,11 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
117
288
|
return false;
|
|
118
289
|
}
|
|
119
290
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
291
|
+
/** Windows process ownership uses immutable StartTime/FileTime provenance through
|
|
292
|
+
* the shared native/platform authority. Without authority, numeric PIDs are never trusted. */
|
|
293
|
+
function defaultPidIncarnation(pid: number): string | undefined {
|
|
294
|
+
return processIncarnation(pid);
|
|
124
295
|
}
|
|
125
|
-
|
|
126
296
|
function runtimeInfo(execPath?: string): DaemonRuntimeInfo {
|
|
127
297
|
const rt = resolveGjcRuntimeSpawnInfo(execPath ?? process.execPath);
|
|
128
298
|
return {
|
|
@@ -219,6 +389,15 @@ export function buildChatDaemonSpawnArgs(input: {
|
|
|
219
389
|
};
|
|
220
390
|
}
|
|
221
391
|
|
|
392
|
+
type ChatDaemonStateClassification =
|
|
393
|
+
| "absent"
|
|
394
|
+
| "replaceable"
|
|
395
|
+
| "compatible"
|
|
396
|
+
| "newer"
|
|
397
|
+
| "malformed"
|
|
398
|
+
| "unauthorized"
|
|
399
|
+
| "stopped";
|
|
400
|
+
|
|
222
401
|
export class ChatDaemonController implements BuiltInDaemonController {
|
|
223
402
|
readonly kind: ChatDaemonKind;
|
|
224
403
|
constructor(
|
|
@@ -237,20 +416,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
237
416
|
async status(): Promise<DaemonStatus> {
|
|
238
417
|
const runtime = runtimeInfo(this.deps.execPath);
|
|
239
418
|
const identity = this.identity();
|
|
240
|
-
if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
|
|
241
419
|
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
state.version === 1 &&
|
|
245
|
-
state.kind === this.kind &&
|
|
246
|
-
state.identity === identity &&
|
|
247
|
-
!state.stoppedAt &&
|
|
248
|
-
state.transportHealthy &&
|
|
249
|
-
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
250
|
-
this.alive(state.pid) &&
|
|
251
|
-
this.incarnation(state.pid) === state.incarnation,
|
|
252
|
-
);
|
|
253
|
-
const health: DaemonHealth = live ? "running" : state && !state.stoppedAt ? "stale" : "stopped";
|
|
420
|
+
if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
|
|
421
|
+
const health: DaemonHealth = this.stateHealth(state, identity);
|
|
254
422
|
return {
|
|
255
423
|
kind: this.kind,
|
|
256
424
|
configured: true,
|
|
@@ -272,14 +440,27 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
272
440
|
const identity = this.identity();
|
|
273
441
|
if (!identity) return "disabled";
|
|
274
442
|
const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
275
|
-
|
|
276
|
-
|
|
443
|
+
const classification = this.classify(existing, identity);
|
|
444
|
+
if (classification === "malformed" || classification === "unauthorized")
|
|
445
|
+
throw new Error(`Unable to replace unauthorized ${this.kind} daemon owner`);
|
|
446
|
+
if (existing && this.isSignalableMatchingOwner(existing)) {
|
|
447
|
+
if (classification === "compatible" || classification === "newer") {
|
|
448
|
+
// A compatible, physically-live owner may be mid-startup: a concurrent
|
|
449
|
+
// ensure can have just acquired ownership and published transportHealthy:false
|
|
450
|
+
// before its transport heartbeats healthy. Wait bounded for that owner to
|
|
451
|
+
// become attachable instead of failing a racing startup outright.
|
|
452
|
+
if (this.isHealthyFreshState(existing) || (await this.waitForOwnership(existing.ownerId, identity)))
|
|
453
|
+
return "attached";
|
|
454
|
+
if (classification === "newer")
|
|
455
|
+
throw new Error(`Unable to replace newer ${this.kind} daemon owner; upgrade this controller`);
|
|
456
|
+
throw new Error(`Unable to replace unhealthy ${this.kind} daemon owner`);
|
|
457
|
+
}
|
|
277
458
|
await this.stopForReplacement(existing);
|
|
278
459
|
}
|
|
279
460
|
const spawned = await this.spawn();
|
|
280
461
|
if (spawned) return "owner_spawned";
|
|
281
462
|
const replacement = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
282
|
-
if (replacement &&
|
|
463
|
+
if (replacement && this.isCurrentCompatibleState(replacement, identity)) return "attached";
|
|
283
464
|
throw new Error(`Unable to attach or spawn ${this.kind} daemon owner`);
|
|
284
465
|
}
|
|
285
466
|
|
|
@@ -288,24 +469,54 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
288
469
|
const warnings = before.runtime.warning ? [before.runtime.warning] : [];
|
|
289
470
|
if (!before.configured)
|
|
290
471
|
return this.result(action, false, `${this.kind} notifications are not configured`, before, before, warnings);
|
|
291
|
-
|
|
292
|
-
|
|
472
|
+
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
473
|
+
const classification = this.classify(state, this.identity());
|
|
474
|
+
if (classification === "newer")
|
|
475
|
+
return this.result(
|
|
476
|
+
action,
|
|
477
|
+
false,
|
|
478
|
+
`${this.kind} daemon is newer than this controller; upgrade this controller before ${action}`,
|
|
479
|
+
before,
|
|
480
|
+
before,
|
|
481
|
+
warnings,
|
|
482
|
+
);
|
|
483
|
+
if (classification === "malformed" || classification === "unauthorized")
|
|
484
|
+
return this.result(
|
|
485
|
+
action,
|
|
486
|
+
false,
|
|
487
|
+
`${this.kind} daemon ownership changed; refusing to signal`,
|
|
488
|
+
before,
|
|
489
|
+
await this.status(),
|
|
490
|
+
warnings,
|
|
491
|
+
);
|
|
492
|
+
if (!state || !this.isSignalableMatchingOwner(state)) {
|
|
493
|
+
if (action === "stop") {
|
|
494
|
+
if (state && this.isAmbiguouslyLiveState(state))
|
|
495
|
+
return this.result(
|
|
496
|
+
action,
|
|
497
|
+
false,
|
|
498
|
+
`${this.kind} daemon ownership changed; refusing to signal`,
|
|
499
|
+
before,
|
|
500
|
+
before,
|
|
501
|
+
warnings,
|
|
502
|
+
);
|
|
293
503
|
return this.result(action, true, `no running ${this.kind} daemon`, before, before, warnings);
|
|
504
|
+
}
|
|
294
505
|
if (opts.spawnIfStopped === false)
|
|
295
506
|
return this.result(action, true, `no running ${this.kind} daemon to reload`, before, before, warnings);
|
|
296
507
|
const spawned = await this.spawn();
|
|
297
|
-
const after = await this.status();
|
|
298
508
|
return this.result(
|
|
299
509
|
action,
|
|
300
510
|
spawned,
|
|
301
|
-
spawned
|
|
511
|
+
spawned
|
|
512
|
+
? `spawned fresh ${this.kind} daemon`
|
|
513
|
+
: `${this.kind} daemon did not publish ownership after spawning`,
|
|
302
514
|
before,
|
|
303
|
-
|
|
515
|
+
await this.status(),
|
|
304
516
|
warnings,
|
|
305
517
|
);
|
|
306
518
|
}
|
|
307
|
-
|
|
308
|
-
if (!state || !this.ownsCapturedState(state, before))
|
|
519
|
+
if (state.identity !== this.identity() || !this.ownsCapturedState(state, before))
|
|
309
520
|
return this.result(
|
|
310
521
|
action,
|
|
311
522
|
false,
|
|
@@ -359,20 +570,75 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
359
570
|
private incarnation(pid: number): string | undefined {
|
|
360
571
|
return (this.deps.pidIncarnation ?? processIncarnation)(pid);
|
|
361
572
|
}
|
|
362
|
-
private
|
|
573
|
+
private processReference(pid: number): ChatDaemonProcessReference | undefined {
|
|
574
|
+
return this.deps.processReference
|
|
575
|
+
? this.deps.processReference(pid)
|
|
576
|
+
: defaultProcessReference(pid, this.deps.platform);
|
|
577
|
+
}
|
|
578
|
+
private isDefinitelyStoppedState(state: ChatDaemonState | undefined): boolean {
|
|
579
|
+
if (isExactPreUpgradeUnavailableChatDaemonState(state)) return !this.alive(state.pid);
|
|
580
|
+
if (!state || !hasSafeChatDaemonOwnerShape(state)) return false;
|
|
581
|
+
if (!this.alive(state.pid)) return true;
|
|
582
|
+
// Proven PID reuse means this persisted owner is gone. The distinct live PID
|
|
583
|
+
// remains nonsignalable because isSignalableMatchingOwner requires equality.
|
|
584
|
+
const incarnation = this.incarnation(state.pid);
|
|
585
|
+
return hasProcessIncarnationAuthority(incarnation) && incarnation !== state.incarnation;
|
|
586
|
+
}
|
|
587
|
+
private stateHealth(state: ChatDaemonState | undefined, identity: string): DaemonHealth {
|
|
588
|
+
if (!state || this.isDefinitelyStoppedState(state)) return "stopped";
|
|
589
|
+
if (this.isCurrentCompatibleState(state, identity)) return "running";
|
|
590
|
+
// A PID that is live but cannot prove a matching current incarnation is
|
|
591
|
+
// ambiguous: do not report it ready or overwrite it.
|
|
592
|
+
return "stale";
|
|
593
|
+
}
|
|
594
|
+
private isSignalableMatchingOwner(state: ChatDaemonState): boolean {
|
|
595
|
+
const incarnation = this.incarnation(state.pid);
|
|
363
596
|
return (
|
|
364
|
-
state
|
|
597
|
+
hasSafeChatDaemonOwnerShape(state) &&
|
|
365
598
|
state.kind === this.kind &&
|
|
366
|
-
|
|
367
|
-
state.transportHealthy &&
|
|
368
|
-
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
599
|
+
state.stoppedAt === undefined &&
|
|
369
600
|
this.alive(state.pid) &&
|
|
370
|
-
|
|
371
|
-
|
|
601
|
+
hasProcessIncarnationAuthority(incarnation) &&
|
|
602
|
+
incarnation === state.incarnation
|
|
372
603
|
);
|
|
373
604
|
}
|
|
605
|
+
/** A live PID with an invalid ownership record is never safe to overwrite. */
|
|
606
|
+
private isAmbiguouslyLiveState(state: ChatDaemonState): boolean {
|
|
607
|
+
return !this.isDefinitelyStoppedState(state) && !this.isSignalableMatchingOwner(state);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private isHealthyFreshState(state: ChatDaemonState): boolean {
|
|
611
|
+
return (
|
|
612
|
+
hasSafeChatDaemonStateShape(state) &&
|
|
613
|
+
state.transportHealthy &&
|
|
614
|
+
Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
private classify(state: ChatDaemonState | undefined, identity: string | undefined): ChatDaemonStateClassification {
|
|
618
|
+
if (!state) return "absent";
|
|
619
|
+
if (isExactPreUpgradeUnavailableChatDaemonState(state)) return this.alive(state.pid) ? "malformed" : "stopped";
|
|
620
|
+
if (!hasSafeChatDaemonOwnerShape(state)) return "malformed";
|
|
621
|
+
if (this.isDefinitelyStoppedState(state)) return "stopped";
|
|
622
|
+
if (!identity || state.kind !== this.kind || state.identity !== identity) return "unauthorized";
|
|
623
|
+
if (hasSafeChatDaemonStateShape(state)) {
|
|
624
|
+
if (state.generation < chatDaemonGeneration(this.kind)) return "replaceable";
|
|
625
|
+
return state.generation > chatDaemonGeneration(this.kind) ? "newer" : "compatible";
|
|
626
|
+
}
|
|
627
|
+
const generation = (state as { generation?: unknown }).generation;
|
|
628
|
+
return isRecognizedLegacyGeneration(generation) ? "replaceable" : "malformed";
|
|
629
|
+
}
|
|
630
|
+
private isCurrentCompatibleState(state: ChatDaemonState, identity: string): boolean {
|
|
631
|
+
const classification = this.classify(state, identity);
|
|
632
|
+
return (
|
|
633
|
+
this.isSignalableMatchingOwner(state) &&
|
|
634
|
+
this.isHealthyFreshState(state) &&
|
|
635
|
+
(classification === "compatible" || classification === "newer")
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
374
639
|
private async stopForReplacement(state: ChatDaemonState): Promise<void> {
|
|
375
|
-
if (!this.
|
|
640
|
+
if (!this.isSignalableMatchingOwner(state)) return;
|
|
641
|
+
|
|
376
642
|
const requestId = this.deps.randomId?.() ?? crypto.randomUUID();
|
|
377
643
|
await writeChatDaemonControlRequest(this.settings.getAgentDir(), this.kind, {
|
|
378
644
|
version: 1,
|
|
@@ -403,21 +669,35 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
403
669
|
state.ownerId === before.ownerId &&
|
|
404
670
|
state.pid === before.pid &&
|
|
405
671
|
Boolean(state.incarnation) &&
|
|
406
|
-
this.
|
|
672
|
+
this.isSignalableMatchingOwner(state)
|
|
407
673
|
);
|
|
408
674
|
}
|
|
409
675
|
private async signalIfOwner(state: ChatDaemonState, signal: NodeJS.Signals): Promise<boolean> {
|
|
410
676
|
const current = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
677
|
+
const identity = this.identity();
|
|
678
|
+
const classification = this.classify(current, identity);
|
|
411
679
|
if (
|
|
680
|
+
!identity ||
|
|
412
681
|
!current ||
|
|
413
682
|
current.ownerId !== state.ownerId ||
|
|
414
683
|
current.pid !== state.pid ||
|
|
684
|
+
current.identity !== state.identity ||
|
|
415
685
|
current.incarnation !== state.incarnation ||
|
|
416
|
-
|
|
686
|
+
current.generation !== state.generation ||
|
|
687
|
+
(classification !== "compatible" && classification !== "replaceable") ||
|
|
688
|
+
!this.isSignalableMatchingOwner(current)
|
|
417
689
|
)
|
|
418
690
|
return false;
|
|
419
|
-
|
|
420
|
-
|
|
691
|
+
const processRef = this.processReference(state.pid);
|
|
692
|
+
// Numeric PIDs can be reused after the ordinary provenance recheck. Only the
|
|
693
|
+
// native stable reference may perform this privileged signal operation.
|
|
694
|
+
if (!processRef || processRef.incarnation !== state.incarnation) return false;
|
|
695
|
+
try {
|
|
696
|
+
processRef.signalRoot(signal);
|
|
697
|
+
return true;
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
421
701
|
}
|
|
422
702
|
private async ownerChanged(
|
|
423
703
|
action: ChatDaemonAction,
|
|
@@ -459,15 +739,10 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
459
739
|
const paths = chatDaemonPaths(this.settings.getAgentDir(), this.kind);
|
|
460
740
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
461
741
|
const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
Date.now() - existing.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
467
|
-
this.alive(existing.pid) &&
|
|
468
|
-
this.incarnation(existing.pid) === existing.incarnation
|
|
469
|
-
)
|
|
470
|
-
return false;
|
|
742
|
+
const classification = this.classify(existing, identity);
|
|
743
|
+
if (classification === "malformed" || classification === "unauthorized") return false;
|
|
744
|
+
if (existing && (this.isSignalableMatchingOwner(existing) || this.isAmbiguouslyLiveState(existing))) return false;
|
|
745
|
+
|
|
471
746
|
const ownerId = `${this.deps.ownerPid ?? process.ppid}-${this.deps.randomId?.() ?? crypto.randomUUID()}`;
|
|
472
747
|
const { command, args } = buildChatDaemonSpawnArgs({
|
|
473
748
|
kind: this.kind,
|
|
@@ -479,7 +754,26 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
479
754
|
detached: true,
|
|
480
755
|
stdio: "ignore",
|
|
481
756
|
}).unref?.();
|
|
482
|
-
return
|
|
757
|
+
return await this.waitForOwnership(ownerId, identity);
|
|
758
|
+
}
|
|
759
|
+
private async waitForOwnership(ownerId: string, identity: string): Promise<boolean> {
|
|
760
|
+
const timeoutMs = Math.max(this.deps.spawnReadyTimeoutMs ?? DEFAULT_SPAWN_READY_TIMEOUT_MS, 0);
|
|
761
|
+
const until = Date.now() + timeoutMs;
|
|
762
|
+
const maxPolls = Math.ceil(timeoutMs / 25);
|
|
763
|
+
for (let poll = 0; poll <= maxPolls; poll++) {
|
|
764
|
+
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
765
|
+
const classification = state ? this.classify(state, identity) : undefined;
|
|
766
|
+
if (
|
|
767
|
+
state &&
|
|
768
|
+
state.ownerId === ownerId &&
|
|
769
|
+
(classification === "compatible" || classification === "newer") &&
|
|
770
|
+
this.isCurrentCompatibleState(state, identity)
|
|
771
|
+
)
|
|
772
|
+
return true;
|
|
773
|
+
if (Date.now() >= until || poll === maxPolls) return false;
|
|
774
|
+
await this.sleep(25);
|
|
775
|
+
}
|
|
776
|
+
return false;
|
|
483
777
|
}
|
|
484
778
|
}
|
|
485
779
|
|
|
@@ -523,44 +817,132 @@ export async function acquireChatDaemonOwnership(input: {
|
|
|
523
817
|
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
524
818
|
pidIncarnation: input.pidIncarnation ?? processIncarnation,
|
|
525
819
|
};
|
|
526
|
-
const incarnation = input.incarnation ?? probe.pidIncarnation(pid)
|
|
820
|
+
const incarnation = input.incarnation ?? probe.pidIncarnation(pid);
|
|
821
|
+
if (!hasProcessIncarnationAuthority(incarnation)) return false;
|
|
822
|
+
|
|
527
823
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
528
|
-
|
|
824
|
+
const existing = await readJson<unknown>(paths.state);
|
|
825
|
+
// A live record remains fenced unless authoritative provenance proves the PID
|
|
826
|
+
// was reused. This permits recovery without ever signaling that replacement.
|
|
827
|
+
if (hasChatDaemonStatePid(existing) && probe.pidAlive(existing.pid)) {
|
|
828
|
+
const current = probe.pidIncarnation(existing.pid);
|
|
829
|
+
if (
|
|
830
|
+
!hasSafeChatDaemonOwnerShape(existing) ||
|
|
831
|
+
!hasProcessIncarnationAuthority(current) ||
|
|
832
|
+
current === existing.incarnation
|
|
833
|
+
)
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
const owner = { pid, incarnation, createdAt: Date.now() };
|
|
837
|
+
let lock = await createChatDaemonOwnerLock(paths.lock, owner);
|
|
838
|
+
if (!lock) {
|
|
529
839
|
if (!(await reclaimChatDaemonOwnerLock(paths.lock, paths.state, probe))) return false;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
await withStateWriteLock(
|
|
534
|
-
paths.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
840
|
+
lock = await createChatDaemonOwnerLock(paths.lock, owner);
|
|
841
|
+
if (!lock) return false;
|
|
842
|
+
}
|
|
843
|
+
return await withStateWriteLock(paths.state, async () => {
|
|
844
|
+
if (!(await ownsChatDaemonOwnerLock(paths.lock, lock))) return false;
|
|
845
|
+
await writeJson(paths.state, {
|
|
846
|
+
version: 1,
|
|
847
|
+
kind: input.kind,
|
|
848
|
+
pid,
|
|
849
|
+
ownerId: input.ownerId,
|
|
850
|
+
identity: input.identity,
|
|
851
|
+
incarnation,
|
|
852
|
+
startedAt: Date.now(),
|
|
853
|
+
heartbeatAt: Date.now(),
|
|
854
|
+
transportHealthy: false,
|
|
855
|
+
generation: chatDaemonGeneration(input.kind),
|
|
856
|
+
} satisfies ChatDaemonState);
|
|
857
|
+
return true;
|
|
858
|
+
});
|
|
549
859
|
}
|
|
550
860
|
|
|
551
|
-
async function createChatDaemonOwnerLock(
|
|
861
|
+
async function createChatDaemonOwnerLock(
|
|
862
|
+
lock: string,
|
|
863
|
+
owner: ChatDaemonOwnerLock,
|
|
864
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
865
|
+
const content = `${JSON.stringify(owner)}\n`;
|
|
866
|
+
const temporary = `${lock}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
552
867
|
try {
|
|
553
|
-
const handle = await fs.promises.open(
|
|
868
|
+
const handle = await fs.promises.open(temporary, "wx", 0o600);
|
|
554
869
|
try {
|
|
555
|
-
await handle.writeFile(
|
|
870
|
+
await handle.writeFile(content, "utf8");
|
|
556
871
|
await handle.sync();
|
|
557
872
|
} finally {
|
|
558
873
|
await handle.close();
|
|
559
874
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
875
|
+
try {
|
|
876
|
+
await fs.promises.link(temporary, lock);
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (isAlreadyExists(error)) return undefined;
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
return await captureChatDaemonOwnerLockLease(lock);
|
|
882
|
+
} finally {
|
|
883
|
+
await fs.promises.unlink(temporary).catch(() => undefined);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function captureChatDaemonOwnerLockLease(lock: string): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
888
|
+
try {
|
|
889
|
+
const handle = await fs.promises.open(lock, "r");
|
|
890
|
+
try {
|
|
891
|
+
const before = await handle.stat({ bigint: true });
|
|
892
|
+
if (!before.isFile()) return undefined;
|
|
893
|
+
const content = await handle.readFile({ encoding: "utf8" });
|
|
894
|
+
const after = await handle.stat({ bigint: true });
|
|
895
|
+
const pathname = await fs.promises.lstat(lock, { bigint: true });
|
|
896
|
+
if (
|
|
897
|
+
!after.isFile() ||
|
|
898
|
+
!pathname.isFile() ||
|
|
899
|
+
pathname.isSymbolicLink() ||
|
|
900
|
+
before.dev !== after.dev ||
|
|
901
|
+
before.ino !== after.ino ||
|
|
902
|
+
before.size !== after.size ||
|
|
903
|
+
before.mtimeNs !== after.mtimeNs ||
|
|
904
|
+
before.dev !== pathname.dev ||
|
|
905
|
+
before.ino !== pathname.ino ||
|
|
906
|
+
before.size !== pathname.size ||
|
|
907
|
+
before.mtimeNs !== pathname.mtimeNs
|
|
908
|
+
)
|
|
909
|
+
return undefined;
|
|
910
|
+
return {
|
|
911
|
+
content,
|
|
912
|
+
dev: before.dev,
|
|
913
|
+
ino: before.ino,
|
|
914
|
+
size: before.size,
|
|
915
|
+
mtimeNs: before.mtimeNs,
|
|
916
|
+
sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
|
917
|
+
};
|
|
918
|
+
} finally {
|
|
919
|
+
await handle.close();
|
|
920
|
+
}
|
|
921
|
+
} catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): Promise<boolean> {
|
|
927
|
+
const current = await captureChatDaemonOwnerLockLease(lock);
|
|
928
|
+
return (
|
|
929
|
+
current?.dev === lease.dev &&
|
|
930
|
+
current.ino === lease.ino &&
|
|
931
|
+
current.size === lease.size &&
|
|
932
|
+
current.mtimeNs === lease.mtimeNs &&
|
|
933
|
+
current.content === lease.content
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Deletes only the exact lease observed by this contender; a successor is retained. */
|
|
938
|
+
function unlinkExactChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): boolean {
|
|
939
|
+
try {
|
|
940
|
+
return native.exactUnlink(lock, {
|
|
941
|
+
...lease,
|
|
942
|
+
quarantineName: `.gjc-delete-chat-daemon-lock-${crypto.randomUUID()}`,
|
|
943
|
+
}).ok;
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
564
946
|
}
|
|
565
947
|
}
|
|
566
948
|
|
|
@@ -570,64 +952,68 @@ async function reclaimChatDaemonOwnerLock(
|
|
|
570
952
|
probe: ChatDaemonOwnershipProbe,
|
|
571
953
|
): Promise<boolean> {
|
|
572
954
|
if (!(await canReclaimChatDaemonOwnerLock(lock, stateFile, probe))) return false;
|
|
573
|
-
|
|
574
955
|
const reclaimFile = `${lock}.reclaim`;
|
|
575
956
|
const reclaimLock = await acquireChatDaemonReclaimLock(reclaimFile, probe);
|
|
576
|
-
|
|
577
957
|
if (!reclaimLock) return false;
|
|
578
958
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
await fs.promises.unlink(lock).catch(() => undefined);
|
|
582
|
-
return true;
|
|
959
|
+
const ownerLock = await canReclaimChatDaemonOwnerLock(lock, stateFile, probe);
|
|
960
|
+
return !!ownerLock && unlinkExactChatDaemonOwnerLock(lock, ownerLock);
|
|
583
961
|
} finally {
|
|
584
|
-
|
|
585
|
-
await fs.promises.unlink(reclaimFile).catch(() => undefined);
|
|
962
|
+
unlinkExactChatDaemonOwnerLock(reclaimFile, reclaimLock);
|
|
586
963
|
}
|
|
587
964
|
}
|
|
588
965
|
|
|
589
966
|
async function acquireChatDaemonReclaimLock(
|
|
590
967
|
reclaimFile: string,
|
|
591
968
|
probe: ChatDaemonOwnershipProbe,
|
|
592
|
-
): Promise<
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (!
|
|
600
|
-
await
|
|
601
|
-
if (!(await createChatDaemonOwnerLock(reclaimFile, owner))) return undefined;
|
|
602
|
-
return await fs.promises.open(reclaimFile, "r+");
|
|
969
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
970
|
+
const incarnation = probe.pidIncarnation(process.pid);
|
|
971
|
+
if (!hasProcessIncarnationAuthority(incarnation)) return undefined;
|
|
972
|
+
const owner: ChatDaemonOwnerLock = { pid: process.pid, incarnation, createdAt: Date.now() };
|
|
973
|
+
const created = await createChatDaemonOwnerLock(reclaimFile, owner);
|
|
974
|
+
if (created) return created;
|
|
975
|
+
const stale = await staleChatDaemonLockLease(reclaimFile, probe);
|
|
976
|
+
if (!stale || !unlinkExactChatDaemonOwnerLock(reclaimFile, stale)) return undefined;
|
|
977
|
+
return await createChatDaemonOwnerLock(reclaimFile, owner);
|
|
603
978
|
}
|
|
604
979
|
|
|
605
|
-
async function
|
|
980
|
+
async function staleChatDaemonLockLease(
|
|
981
|
+
lock: string,
|
|
982
|
+
probe: ChatDaemonOwnershipProbe,
|
|
983
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
984
|
+
const lease = await captureChatDaemonOwnerLockLease(lock);
|
|
985
|
+
if (!lease) return undefined;
|
|
606
986
|
let owner: unknown;
|
|
607
987
|
try {
|
|
608
|
-
owner = JSON.parse(
|
|
609
|
-
} catch {
|
|
610
|
-
|
|
611
|
-
const currentIncarnation = probe.pidIncarnation(owner.pid);
|
|
612
|
-
return (
|
|
613
|
-
!probe.pidAlive(owner.pid) ||
|
|
614
|
-
(owner.incarnation !== "unavailable" &&
|
|
615
|
-
(!isProcessIncarnation(owner.incarnation) ||
|
|
616
|
-
(currentIncarnation !== undefined && currentIncarnation !== owner.incarnation)))
|
|
617
|
-
);
|
|
988
|
+
owner = JSON.parse(lease.content);
|
|
989
|
+
} catch {
|
|
990
|
+
return undefined;
|
|
618
991
|
}
|
|
619
|
-
|
|
620
|
-
|
|
992
|
+
if (!isChatDaemonOwnerLock(owner)) return undefined;
|
|
993
|
+
if (!probe.pidAlive(owner.pid)) return lease;
|
|
994
|
+
if (!hasProcessIncarnationAuthority(owner.incarnation)) return undefined;
|
|
995
|
+
const currentIncarnation = probe.pidIncarnation(owner.pid);
|
|
996
|
+
return hasProcessIncarnationAuthority(currentIncarnation) && currentIncarnation !== owner.incarnation
|
|
997
|
+
? lease
|
|
998
|
+
: undefined;
|
|
621
999
|
}
|
|
622
1000
|
|
|
623
1001
|
async function canReclaimChatDaemonOwnerLock(
|
|
624
1002
|
lock: string,
|
|
625
1003
|
stateFile: string,
|
|
626
1004
|
probe: ChatDaemonOwnershipProbe,
|
|
627
|
-
): Promise<
|
|
628
|
-
const state = await readJson<
|
|
629
|
-
if (
|
|
630
|
-
|
|
1005
|
+
): Promise<ChatDaemonOwnerLockLease | undefined> {
|
|
1006
|
+
const state = await readJson<unknown>(stateFile);
|
|
1007
|
+
if (hasChatDaemonStatePid(state) && probe.pidAlive(state.pid)) {
|
|
1008
|
+
const current = probe.pidIncarnation(state.pid);
|
|
1009
|
+
if (
|
|
1010
|
+
!hasSafeChatDaemonOwnerShape(state) ||
|
|
1011
|
+
!hasProcessIncarnationAuthority(current) ||
|
|
1012
|
+
current === state.incarnation
|
|
1013
|
+
)
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
return await staleChatDaemonLockLease(lock, probe);
|
|
631
1017
|
}
|
|
632
1018
|
|
|
633
1019
|
function isChatDaemonOwnerLock(value: unknown): value is ChatDaemonOwnerLock {
|
|
@@ -652,17 +1038,25 @@ export async function renewChatDaemonHeartbeat(input: {
|
|
|
652
1038
|
pid?: number;
|
|
653
1039
|
incarnation?: string;
|
|
654
1040
|
transportHealthy: boolean;
|
|
1041
|
+
pidAlive?: (pid: number) => boolean;
|
|
1042
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
655
1043
|
}): Promise<boolean> {
|
|
656
1044
|
const paths = chatDaemonPaths(input.agentDir, input.kind);
|
|
1045
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1046
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
657
1047
|
return await withStateWriteLock(paths.state, async () => {
|
|
658
|
-
const state = await readJson<
|
|
659
|
-
|
|
1048
|
+
const state = await readJson<unknown>(paths.state);
|
|
1049
|
+
if (!hasSafeChatDaemonStateShape(state)) return false;
|
|
1050
|
+
const pid = input.pid ?? state.pid;
|
|
1051
|
+
const currentIncarnation = pidIncarnation(pid);
|
|
660
1052
|
if (
|
|
661
|
-
!state ||
|
|
662
1053
|
state.ownerId !== input.ownerId ||
|
|
663
1054
|
pid !== state.pid ||
|
|
664
|
-
!input.incarnation ||
|
|
665
|
-
state.incarnation !== input.incarnation
|
|
1055
|
+
!hasProcessIncarnationAuthority(input.incarnation) ||
|
|
1056
|
+
state.incarnation !== input.incarnation ||
|
|
1057
|
+
!pidAlive(pid) ||
|
|
1058
|
+
!hasProcessIncarnationAuthority(currentIncarnation) ||
|
|
1059
|
+
currentIncarnation !== input.incarnation
|
|
666
1060
|
)
|
|
667
1061
|
return false;
|
|
668
1062
|
await writeJson(paths.state, { ...state, heartbeatAt: Date.now(), transportHealthy: input.transportHealthy });
|
|
@@ -673,21 +1067,35 @@ export async function releaseChatDaemonOwnership(input: {
|
|
|
673
1067
|
agentDir: string;
|
|
674
1068
|
kind: ChatDaemonKind;
|
|
675
1069
|
ownerId: string;
|
|
676
|
-
pid
|
|
677
|
-
incarnation
|
|
1070
|
+
pid: number;
|
|
1071
|
+
incarnation: string;
|
|
1072
|
+
pidAlive?: (pid: number) => boolean;
|
|
1073
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
678
1074
|
}): Promise<void> {
|
|
679
1075
|
const paths = chatDaemonPaths(input.agentDir, input.kind);
|
|
1076
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1077
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
680
1078
|
await withStateWriteLock(paths.state, async () => {
|
|
681
|
-
const state = await readJson<
|
|
1079
|
+
const state = await readJson<unknown>(paths.state);
|
|
1080
|
+
const currentIncarnation = pidIncarnation(input.pid);
|
|
682
1081
|
if (
|
|
683
|
-
state
|
|
684
|
-
(input.
|
|
685
|
-
|
|
1082
|
+
!hasSafeChatDaemonStateShape(state) ||
|
|
1083
|
+
!hasProcessIncarnationAuthority(input.incarnation) ||
|
|
1084
|
+
state.ownerId !== input.ownerId ||
|
|
1085
|
+
state.pid !== input.pid ||
|
|
1086
|
+
state.incarnation !== input.incarnation ||
|
|
1087
|
+
!pidAlive(input.pid) ||
|
|
1088
|
+
!hasProcessIncarnationAuthority(currentIncarnation) ||
|
|
1089
|
+
currentIncarnation !== input.incarnation
|
|
686
1090
|
)
|
|
687
1091
|
return;
|
|
688
1092
|
await writeJson(paths.state, { ...state, stoppedAt: Date.now(), transportHealthy: false });
|
|
689
|
-
const lock = await
|
|
690
|
-
|
|
691
|
-
|
|
1093
|
+
const lock = await captureChatDaemonOwnerLockLease(paths.lock);
|
|
1094
|
+
let owner: unknown;
|
|
1095
|
+
try {
|
|
1096
|
+
owner = lock && JSON.parse(lock.content);
|
|
1097
|
+
} catch {}
|
|
1098
|
+
if (lock && isChatDaemonOwnerLock(owner) && owner.pid === state.pid && owner.incarnation === state.incarnation)
|
|
1099
|
+
unlinkExactChatDaemonOwnerLock(paths.lock, lock);
|
|
692
1100
|
});
|
|
693
1101
|
}
|