@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
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
12
13
|
import * as path from "node:path";
|
|
14
|
+
import { Process } from "@gajae-code/natives";
|
|
13
15
|
import type { Settings } from "../../config/settings";
|
|
14
16
|
import type {
|
|
15
17
|
BuiltInDaemonController,
|
|
@@ -22,18 +24,31 @@ import type {
|
|
|
22
24
|
} from "../../daemon/control-types";
|
|
23
25
|
import { OWNERSHIP_MISMATCH_MESSAGE, ownershipMismatchRecovery } from "../../daemon/operator-contract";
|
|
24
26
|
import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
|
|
27
|
+
import { isProcessIncarnation } from "../broker/process-incarnation";
|
|
25
28
|
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
29
|
+
import { exactUnlinkNotificationFile, readNotificationEndpointFile } from "./notification-service";
|
|
26
30
|
import {
|
|
31
|
+
confirmTelegramDaemonSpawn,
|
|
32
|
+
type DaemonState,
|
|
27
33
|
daemonPaths,
|
|
34
|
+
hasSafeDaemonStateShape,
|
|
35
|
+
isCurrentCompatibleOwner,
|
|
28
36
|
isFreshLiveOwner,
|
|
37
|
+
isSignalableMatchingOwner,
|
|
29
38
|
readDaemonRoots,
|
|
30
39
|
readDaemonState,
|
|
31
40
|
spawnTelegramDaemonOwner,
|
|
32
41
|
type TelegramDaemonDeps,
|
|
33
42
|
type TelegramDaemonFs,
|
|
43
|
+
type TelegramSpawnOwnerResult,
|
|
34
44
|
} from "./telegram-daemon";
|
|
35
45
|
|
|
36
|
-
const nodeFs: TelegramDaemonFs =
|
|
46
|
+
const nodeFs: TelegramDaemonFs = {
|
|
47
|
+
...(fs.promises as unknown as TelegramDaemonFs),
|
|
48
|
+
readEndpointFile: readNotificationEndpointFile,
|
|
49
|
+
exactUnlink: async (file, identity) =>
|
|
50
|
+
exactUnlinkNotificationFile(file, identity, `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`),
|
|
51
|
+
};
|
|
37
52
|
const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
38
53
|
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
|
39
54
|
const DEFAULT_WAIT_STEP_MS = 25;
|
|
@@ -92,11 +107,52 @@ export async function clearTelegramControlRequest(
|
|
|
92
107
|
await fsImpl.unlink(file).catch(() => undefined);
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
export interface DaemonProcessReference {
|
|
111
|
+
incarnation: string;
|
|
112
|
+
signalRoot(signal: NodeJS.Signals): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultProcessReference(pid: number, platform = os.platform()): DaemonProcessReference | undefined {
|
|
116
|
+
try {
|
|
117
|
+
const processRef = Process.fromPid(pid);
|
|
118
|
+
if (!processRef || !isProcessIncarnation(processRef.incarnation)) return undefined;
|
|
119
|
+
const incarnation = processRef.incarnation;
|
|
120
|
+
return {
|
|
121
|
+
incarnation,
|
|
122
|
+
signalRoot: signal => {
|
|
123
|
+
const nativeSignal = os.constants.signals[signal];
|
|
124
|
+
if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
|
|
125
|
+
// macOS exposes no pidfd and the native signal_root is a no-op there, so
|
|
126
|
+
// the daemon control plane previously had NO way to signal a live owner —
|
|
127
|
+
// every stop/reload of a live/hung daemon refused ("ownership changed;
|
|
128
|
+
// refusing to signal"), and only an external `kill -9` could recover it.
|
|
129
|
+
// Signal by numeric PID via kill(2), but re-read the immutable start-time
|
|
130
|
+
// incarnation immediately beforehand so a PID that exited and was reused
|
|
131
|
+
// since capture is never signaled; the residual window is the few
|
|
132
|
+
// instructions between this recheck and kill(2).
|
|
133
|
+
if (platform === "darwin") {
|
|
134
|
+
const current = Process.fromPid(pid) as { incarnation?: unknown } | null;
|
|
135
|
+
if (!current || current.incarnation !== incarnation) throw new Error("Pinned process is already gone");
|
|
136
|
+
process.kill(pid, signal);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
|
|
140
|
+
if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
95
148
|
export interface TelegramDaemonControlDeps {
|
|
96
149
|
fs?: TelegramDaemonFs;
|
|
97
150
|
now?: () => number;
|
|
98
151
|
pidAlive?: (pid: number) => boolean;
|
|
99
|
-
|
|
152
|
+
processReference?: (pid: number) => DaemonProcessReference | undefined;
|
|
153
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
154
|
+
/** Test seam for platform-specific default stable-process authority. */
|
|
155
|
+
platform?: NodeJS.Platform;
|
|
100
156
|
spawn?: TelegramDaemonDeps["spawn"];
|
|
101
157
|
execPath?: string;
|
|
102
158
|
/**
|
|
@@ -110,8 +166,14 @@ export interface TelegramDaemonControlDeps {
|
|
|
110
166
|
randomId?: () => string;
|
|
111
167
|
sleep?: (ms: number) => Promise<void>;
|
|
112
168
|
waitStepMs?: number;
|
|
169
|
+
/** Bounded startup-readiness timeout; injectable for deterministic controller tests. */
|
|
170
|
+
readinessTimeoutMs?: number;
|
|
113
171
|
}
|
|
114
172
|
|
|
173
|
+
export type TelegramGenerationReloadResult =
|
|
174
|
+
| { outcome: "ready"; operation: DaemonOperationResult }
|
|
175
|
+
| { outcome: "failed"; operation: DaemonOperationResult };
|
|
176
|
+
|
|
115
177
|
function defaultPidAlive(pid: number): boolean {
|
|
116
178
|
try {
|
|
117
179
|
process.kill(pid, 0);
|
|
@@ -121,20 +183,12 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
121
183
|
}
|
|
122
184
|
}
|
|
123
185
|
|
|
124
|
-
function defaultSendSignal(pid: number, signal: NodeJS.Signals): void {
|
|
125
|
-
try {
|
|
126
|
-
process.kill(pid, signal);
|
|
127
|
-
} catch {
|
|
128
|
-
// Best-effort: the process may already be gone.
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
186
|
export class TelegramDaemonController implements BuiltInDaemonController {
|
|
133
187
|
readonly kind = "telegram" as const;
|
|
134
188
|
private readonly fsImpl: TelegramDaemonFs;
|
|
135
|
-
private readonly now: () => number;
|
|
136
189
|
private readonly pidAlive: (pid: number) => boolean;
|
|
137
|
-
private readonly
|
|
190
|
+
private readonly now: () => number;
|
|
191
|
+
private readonly processReference: (pid: number) => DaemonProcessReference | undefined;
|
|
138
192
|
private readonly waitStepMs: number;
|
|
139
193
|
|
|
140
194
|
constructor(
|
|
@@ -144,7 +198,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
144
198
|
this.fsImpl = deps.fs ?? nodeFs;
|
|
145
199
|
this.now = deps.now ?? Date.now;
|
|
146
200
|
this.pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
147
|
-
this.
|
|
201
|
+
this.processReference = deps.processReference ?? (pid => defaultProcessReference(pid, deps.platform));
|
|
148
202
|
this.waitStepMs = deps.waitStepMs ?? DEFAULT_WAIT_STEP_MS;
|
|
149
203
|
}
|
|
150
204
|
|
|
@@ -167,18 +221,19 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
167
221
|
}
|
|
168
222
|
const state = await readDaemonState(this.settings, this.fsImpl);
|
|
169
223
|
const roots = await readDaemonRoots(this.settings, this.fsImpl);
|
|
170
|
-
const
|
|
171
|
-
state !== undefined
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
224
|
+
const health: DaemonHealth =
|
|
225
|
+
!state || state.stoppedAt !== undefined || !this.pidAlive(state.pid)
|
|
226
|
+
? "stopped"
|
|
227
|
+
: isCurrentCompatibleOwner({
|
|
228
|
+
state,
|
|
229
|
+
now: this.now(),
|
|
230
|
+
tokenFingerprint: tokenFingerprint(cfg.botToken as string),
|
|
231
|
+
chatId: cfg.chatId as string,
|
|
232
|
+
pidAlive: this.pidAlive,
|
|
233
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
234
|
+
})
|
|
235
|
+
? "running"
|
|
236
|
+
: "stale";
|
|
182
237
|
return {
|
|
183
238
|
kind: this.kind,
|
|
184
239
|
configured: true,
|
|
@@ -198,6 +253,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
198
253
|
fs: this.deps.fs,
|
|
199
254
|
now: this.deps.now,
|
|
200
255
|
pidAlive: this.deps.pidAlive,
|
|
256
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
201
257
|
pid: this.deps.ownerPid ?? process.ppid,
|
|
202
258
|
spawn: this.deps.spawn,
|
|
203
259
|
execPath: this.deps.execPath,
|
|
@@ -205,6 +261,32 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
205
261
|
};
|
|
206
262
|
}
|
|
207
263
|
|
|
264
|
+
private async spawnAndWait(
|
|
265
|
+
roots: string[],
|
|
266
|
+
token: string,
|
|
267
|
+
chatId: string,
|
|
268
|
+
): Promise<{ spawned: TelegramSpawnOwnerResult; ready: boolean }> {
|
|
269
|
+
const spawned = await spawnTelegramDaemonOwner(
|
|
270
|
+
{ settings: this.settings, roots, tokenFingerprint: token, chatId },
|
|
271
|
+
this.spawnDeps(),
|
|
272
|
+
);
|
|
273
|
+
const ready = await confirmTelegramDaemonSpawn({
|
|
274
|
+
settings: this.settings,
|
|
275
|
+
spawned,
|
|
276
|
+
tokenFingerprint: token,
|
|
277
|
+
chatId,
|
|
278
|
+
pid: this.deps.ownerPid ?? process.ppid,
|
|
279
|
+
fs: this.fsImpl,
|
|
280
|
+
now: this.now,
|
|
281
|
+
pidAlive: this.pidAlive,
|
|
282
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
283
|
+
sleep: this.deps.sleep,
|
|
284
|
+
waitStepMs: this.waitStepMs,
|
|
285
|
+
timeoutMs: this.deps.readinessTimeoutMs,
|
|
286
|
+
});
|
|
287
|
+
return { spawned, ready };
|
|
288
|
+
}
|
|
289
|
+
|
|
208
290
|
private sleep(ms: number): Promise<void> {
|
|
209
291
|
if (this.deps.sleep) return this.deps.sleep(ms);
|
|
210
292
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -217,14 +299,67 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
217
299
|
*/
|
|
218
300
|
private async waitForPidDeath(pid: number, timeoutMs: number): Promise<boolean> {
|
|
219
301
|
if (!this.pidAlive(pid)) return true;
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
302
|
+
const timeout = Math.max(timeoutMs, 0);
|
|
303
|
+
const waitStepMs = Math.max(this.waitStepMs, 1);
|
|
304
|
+
const deadline = this.now() + timeout;
|
|
305
|
+
const maxPolls = Math.ceil(timeout / waitStepMs);
|
|
306
|
+
for (let poll = 0; poll < maxPolls && this.now() < deadline; poll++) {
|
|
307
|
+
await this.sleep(waitStepMs);
|
|
223
308
|
if (!this.pidAlive(pid)) return true;
|
|
224
309
|
}
|
|
225
310
|
return !this.pidAlive(pid);
|
|
226
311
|
}
|
|
227
312
|
|
|
313
|
+
private async signalCapturedOwner(
|
|
314
|
+
captured: DaemonState,
|
|
315
|
+
tokenFingerprint: string,
|
|
316
|
+
chatId: string,
|
|
317
|
+
signal: NodeJS.Signals,
|
|
318
|
+
): Promise<"signaled" | "already_gone" | "ownership_changed"> {
|
|
319
|
+
const current = await readDaemonState(this.settings, this.fsImpl);
|
|
320
|
+
// The signal is a privileged action: immediately before sending it, prove the
|
|
321
|
+
// complete state record is still the exact captured owner. A malformed record,
|
|
322
|
+
// configuration mutation, or successor using a reused PID is never signalable.
|
|
323
|
+
if (
|
|
324
|
+
!hasSafeDaemonStateShape(current) ||
|
|
325
|
+
current.ownerId !== captured.ownerId ||
|
|
326
|
+
current.acquisitionId !== captured.acquisitionId ||
|
|
327
|
+
current.pid !== captured.pid ||
|
|
328
|
+
current.generation !== captured.generation ||
|
|
329
|
+
current.incarnation !== captured.incarnation ||
|
|
330
|
+
current.tokenFingerprint !== captured.tokenFingerprint ||
|
|
331
|
+
current.chatId !== captured.chatId ||
|
|
332
|
+
current.tokenFingerprint !== tokenFingerprint ||
|
|
333
|
+
current.chatId !== chatId
|
|
334
|
+
)
|
|
335
|
+
return "ownership_changed";
|
|
336
|
+
// A matching captured owner that exited between the request and recheck has
|
|
337
|
+
// completed the handoff. A live owner with changed or unavailable provenance
|
|
338
|
+
// remains ambiguous and must not be treated as stopped.
|
|
339
|
+
if (!this.pidAlive(captured.pid)) return "already_gone";
|
|
340
|
+
if (
|
|
341
|
+
!isSignalableMatchingOwner({
|
|
342
|
+
state: current,
|
|
343
|
+
tokenFingerprint,
|
|
344
|
+
chatId,
|
|
345
|
+
pidAlive: this.pidAlive,
|
|
346
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
347
|
+
})
|
|
348
|
+
)
|
|
349
|
+
return "ownership_changed";
|
|
350
|
+
const processRef = this.processReference(captured.pid);
|
|
351
|
+
// A stable native process reference (pidfd/handle/start-time reference) closes
|
|
352
|
+
// the exit-and-reuse window between ordinary provenance checks and signaling.
|
|
353
|
+
// Its identity must still be the exact persisted incarnation before use.
|
|
354
|
+
if (!processRef || processRef.incarnation !== captured.incarnation) return "ownership_changed";
|
|
355
|
+
try {
|
|
356
|
+
processRef.signalRoot(signal);
|
|
357
|
+
} catch {
|
|
358
|
+
return "already_gone";
|
|
359
|
+
}
|
|
360
|
+
return "signaled";
|
|
361
|
+
}
|
|
362
|
+
|
|
228
363
|
private result(
|
|
229
364
|
action: "stop" | "reload",
|
|
230
365
|
ok: boolean,
|
|
@@ -241,6 +376,20 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
241
376
|
return this.stopOrReload("reload", opts);
|
|
242
377
|
}
|
|
243
378
|
|
|
379
|
+
async reloadForGenerationUpgrade(opts: DaemonOperationOptions = {}): Promise<TelegramGenerationReloadResult> {
|
|
380
|
+
// A generation upgrade MUST replace an incompatible older-generation owner to
|
|
381
|
+
// avoid a permanent single-poller deadlock. Unlike a manual `gjc daemon
|
|
382
|
+
// reload`, this automatic path force-escalates to SIGKILL when the old owner
|
|
383
|
+
// ignores the cooperative SIGTERM within the graceful timeout, so SDK startup
|
|
384
|
+
// self-recovers instead of failing closed and asking the operator to rerun
|
|
385
|
+
// with --force. The SIGKILL remains fenced to the still-live, still-matching
|
|
386
|
+
// captured owner (same ownerId + pid), so a fresh replacement is never killed.
|
|
387
|
+
const operation = await this.stopOrReload("reload", { ...opts, force: true });
|
|
388
|
+
if (!operation.ok) return { outcome: "failed", operation };
|
|
389
|
+
const after = await this.status();
|
|
390
|
+
return after.health === "running" ? { outcome: "ready", operation } : { outcome: "failed", operation };
|
|
391
|
+
}
|
|
392
|
+
|
|
244
393
|
async stop(opts: DaemonOperationOptions = {}): Promise<DaemonOperationResult> {
|
|
245
394
|
return this.stopOrReload("stop", opts);
|
|
246
395
|
}
|
|
@@ -259,19 +408,55 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
259
408
|
const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS;
|
|
260
409
|
const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
|
261
410
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
411
|
+
const state = await readDaemonState(this.settings, this.fsImpl);
|
|
412
|
+
const replaceableLiveOwner =
|
|
413
|
+
(action === "reload" &&
|
|
414
|
+
state !== undefined &&
|
|
415
|
+
isFreshLiveOwner({
|
|
416
|
+
state,
|
|
417
|
+
now: this.now(),
|
|
418
|
+
tokenFingerprint: fp,
|
|
419
|
+
chatId,
|
|
420
|
+
pidAlive: this.pidAlive,
|
|
421
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
422
|
+
}) &&
|
|
423
|
+
isSignalableMatchingOwner({
|
|
424
|
+
state,
|
|
425
|
+
tokenFingerprint: fp,
|
|
426
|
+
chatId,
|
|
427
|
+
pidAlive: this.pidAlive,
|
|
428
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
429
|
+
})) ||
|
|
430
|
+
// A physically-live matching owner whose heartbeat is stale (hung) may be
|
|
431
|
+
// past-TTL yet still holding the poller. Autostart/generation-upgrade reloads
|
|
432
|
+
// stay conservative and refuse it, but an explicit `reload --force` must be
|
|
433
|
+
// able to signal and replace it rather than deadlock behind the live PID.
|
|
434
|
+
(opts.force === true &&
|
|
435
|
+
isSignalableMatchingOwner({
|
|
436
|
+
state,
|
|
437
|
+
tokenFingerprint: fp,
|
|
438
|
+
chatId,
|
|
439
|
+
pidAlive: this.pidAlive,
|
|
440
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
441
|
+
}));
|
|
442
|
+
const stoppableLiveOwner =
|
|
443
|
+
action === "stop" &&
|
|
444
|
+
isSignalableMatchingOwner({
|
|
445
|
+
state,
|
|
446
|
+
tokenFingerprint: fp,
|
|
447
|
+
chatId,
|
|
448
|
+
pidAlive: this.pidAlive,
|
|
449
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
450
|
+
});
|
|
451
|
+
// A stale pre-generation owner may only be moved by reload; manual stop
|
|
452
|
+
// also targets a physically live matching legacy owner, but never spawns.
|
|
453
|
+
if (before.health !== "running" && !replaceableLiveOwner && !stoppableLiveOwner) {
|
|
454
|
+
if (action === "stop")
|
|
265
455
|
return this.result(action, true, "no running telegram daemon", before, before, warnings);
|
|
266
|
-
|
|
267
|
-
const spawnIfStopped = opts.spawnIfStopped ?? true;
|
|
268
|
-
if (!spawnIfStopped) {
|
|
456
|
+
if (!(opts.spawnIfStopped ?? true)) {
|
|
269
457
|
return this.result(action, true, "no running telegram daemon to reload", before, before, warnings);
|
|
270
458
|
}
|
|
271
|
-
const spawned = await
|
|
272
|
-
{ settings: this.settings, roots, tokenFingerprint: fp, chatId },
|
|
273
|
-
this.spawnDeps(),
|
|
274
|
-
);
|
|
459
|
+
const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
|
|
275
460
|
warnings.push(...spawned.warnings);
|
|
276
461
|
const after = await this.status();
|
|
277
462
|
if (spawned.result === "blocked") {
|
|
@@ -287,8 +472,10 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
287
472
|
}
|
|
288
473
|
return this.result(
|
|
289
474
|
action,
|
|
290
|
-
spawned.result === "owner_spawned",
|
|
291
|
-
|
|
475
|
+
spawned.result === "owner_spawned" && ready && after.health === "running",
|
|
476
|
+
ready
|
|
477
|
+
? `spawned fresh telegram daemon (${spawned.result})`
|
|
478
|
+
: "telegram daemon did not become ready after spawning",
|
|
292
479
|
before,
|
|
293
480
|
after,
|
|
294
481
|
warnings,
|
|
@@ -296,15 +483,48 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
296
483
|
}
|
|
297
484
|
|
|
298
485
|
// Running owner: capture identity, request cooperative stop, signal, wait.
|
|
299
|
-
|
|
300
|
-
|
|
486
|
+
if (
|
|
487
|
+
!hasSafeDaemonStateShape(state) ||
|
|
488
|
+
!isSignalableMatchingOwner({
|
|
489
|
+
state,
|
|
490
|
+
tokenFingerprint: fp,
|
|
491
|
+
chatId,
|
|
492
|
+
pidAlive: this.pidAlive,
|
|
493
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
494
|
+
})
|
|
495
|
+
) {
|
|
496
|
+
return this.result(
|
|
497
|
+
action,
|
|
498
|
+
false,
|
|
499
|
+
"telegram daemon ownership changed; refusing to signal",
|
|
500
|
+
before,
|
|
501
|
+
before,
|
|
502
|
+
warnings,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const capturedOwner = state;
|
|
506
|
+
const oldOwnerId = capturedOwner.ownerId;
|
|
507
|
+
const oldPid = capturedOwner.pid;
|
|
301
508
|
const requestId = this.deps.randomId?.() ?? `${this.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
302
509
|
await writeTelegramControlRequest(
|
|
303
510
|
this.settings,
|
|
304
511
|
{ version: 1, requestId, action, ownerId: oldOwnerId, pid: oldPid, createdAt: this.now() },
|
|
305
512
|
this.fsImpl,
|
|
306
513
|
);
|
|
307
|
-
if (
|
|
514
|
+
if ((await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGTERM")) === "ownership_changed") {
|
|
515
|
+
await this.clearOwnRequest(requestId);
|
|
516
|
+
return this.result(
|
|
517
|
+
action,
|
|
518
|
+
false,
|
|
519
|
+
"telegram daemon ownership changed; refusing to signal",
|
|
520
|
+
before,
|
|
521
|
+
await this.status(),
|
|
522
|
+
warnings,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
// "signaled" or "already_gone" (the owner already exited cooperatively): confirm
|
|
526
|
+
// death through the normal path — waitForPidDeath returns immediately for a
|
|
527
|
+
// dead pid, so stop succeeds and reload proceeds to spawn the replacement.
|
|
308
528
|
|
|
309
529
|
let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
|
|
310
530
|
if (!dead) {
|
|
@@ -319,33 +539,36 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
319
539
|
tokenFingerprint: fp,
|
|
320
540
|
chatId,
|
|
321
541
|
pidAlive: this.pidAlive,
|
|
542
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
322
543
|
});
|
|
323
544
|
if (changedToLiveOwner) {
|
|
324
|
-
|
|
325
|
-
// kill it or spawn another; attach to the running daemon.
|
|
326
|
-
await this.clearOwnRequest(requestId, oldOwnerId);
|
|
545
|
+
await this.clearOwnRequest(requestId);
|
|
327
546
|
const after = await this.status();
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
);
|
|
547
|
+
if (!dead) {
|
|
548
|
+
return this.result(
|
|
549
|
+
action,
|
|
550
|
+
false,
|
|
551
|
+
"ownership changed before the captured daemon exited; refusing to signal or spawn",
|
|
552
|
+
before,
|
|
553
|
+
after,
|
|
554
|
+
warnings,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
339
557
|
}
|
|
340
558
|
// No live replacement. Escalate to SIGKILL only with --force and only when
|
|
341
559
|
// the captured owner/pid still matches, so we never kill a different owner.
|
|
342
560
|
const stillSameOwner = current !== undefined && current.ownerId === oldOwnerId && current.pid === oldPid;
|
|
343
|
-
if (opts.force && stillSameOwner
|
|
344
|
-
this.
|
|
345
|
-
|
|
561
|
+
if (opts.force && stillSameOwner) {
|
|
562
|
+
const killResult = await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGKILL");
|
|
563
|
+
// Kill only a still-live matching owner; an owner that exited between the
|
|
564
|
+
// graceful timeout and this recheck ("already_gone") is confirmed dead by
|
|
565
|
+
// waitForPidDeath, while a real ownership change stays fenced.
|
|
566
|
+
if (killResult === "signaled" || killResult === "already_gone") {
|
|
567
|
+
dead = await this.waitForPidDeath(oldPid, killTimeoutMs);
|
|
568
|
+
}
|
|
346
569
|
}
|
|
347
570
|
if (!dead) {
|
|
348
|
-
await this.clearOwnRequest(requestId
|
|
571
|
+
await this.clearOwnRequest(requestId);
|
|
349
572
|
const after = await this.status();
|
|
350
573
|
const message = opts.force
|
|
351
574
|
? "old daemon did not exit after SIGKILL; refusing to spawn to avoid a Telegram 409 conflict"
|
|
@@ -355,22 +578,38 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
355
578
|
}
|
|
356
579
|
|
|
357
580
|
// Old pid is dead: safe to clear our request and proceed.
|
|
358
|
-
await this.clearOwnRequest(requestId
|
|
581
|
+
await this.clearOwnRequest(requestId);
|
|
359
582
|
|
|
360
583
|
if (action === "stop") {
|
|
361
584
|
const after = await this.status();
|
|
362
585
|
return this.result(action, true, "stopped telegram daemon", before, after, warnings);
|
|
363
586
|
}
|
|
364
587
|
|
|
365
|
-
const spawned = await
|
|
366
|
-
{ settings: this.settings, roots, tokenFingerprint: fp, chatId },
|
|
367
|
-
this.spawnDeps(),
|
|
368
|
-
);
|
|
588
|
+
const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
|
|
369
589
|
warnings.push(...spawned.warnings);
|
|
370
590
|
const after = await this.status();
|
|
371
591
|
if (spawned.result === "attached") {
|
|
372
|
-
|
|
373
|
-
|
|
592
|
+
const attachedState = await readDaemonState(this.settings, this.fsImpl);
|
|
593
|
+
if (
|
|
594
|
+
!isCurrentCompatibleOwner({
|
|
595
|
+
state: attachedState,
|
|
596
|
+
now: this.now(),
|
|
597
|
+
tokenFingerprint: fp,
|
|
598
|
+
chatId,
|
|
599
|
+
pidAlive: this.pidAlive,
|
|
600
|
+
pidIncarnation: this.deps.pidIncarnation,
|
|
601
|
+
})
|
|
602
|
+
) {
|
|
603
|
+
return this.result(
|
|
604
|
+
action,
|
|
605
|
+
false,
|
|
606
|
+
"ownership moved to a non-ready or incompatible daemon; reload required",
|
|
607
|
+
before,
|
|
608
|
+
after,
|
|
609
|
+
warnings,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
warnings.push("a ready current-compatible owner already exists; attached instead of spawning");
|
|
374
613
|
} else if (after.ownerId && after.ownerId === oldOwnerId) {
|
|
375
614
|
warnings.push("owner id unchanged after reload");
|
|
376
615
|
}
|
|
@@ -387,20 +626,17 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
387
626
|
}
|
|
388
627
|
return this.result(
|
|
389
628
|
action,
|
|
390
|
-
spawned.result
|
|
391
|
-
`reloaded telegram daemon (${spawned.result})
|
|
629
|
+
(spawned.result === "owner_spawned" || spawned.result === "attached") && ready && after.health === "running",
|
|
630
|
+
ready ? `reloaded telegram daemon (${spawned.result})` : "telegram daemon did not become ready after spawning",
|
|
392
631
|
before,
|
|
393
632
|
after,
|
|
394
633
|
warnings,
|
|
395
634
|
);
|
|
396
635
|
}
|
|
397
636
|
|
|
398
|
-
/** Clear our
|
|
399
|
-
private async clearOwnRequest(requestId: string
|
|
637
|
+
/** Clear only our exact control request; a successor request must survive. */
|
|
638
|
+
private async clearOwnRequest(requestId: string): Promise<void> {
|
|
400
639
|
const current = await readTelegramControlRequest(this.settings, this.fsImpl);
|
|
401
|
-
if (
|
|
402
|
-
if (current.requestId === requestId || current.ownerId === oldOwnerId) {
|
|
403
|
-
await clearTelegramControlRequest(this.settings, current.requestId, this.fsImpl);
|
|
404
|
-
}
|
|
640
|
+
if (current?.requestId === requestId) await clearTelegramControlRequest(this.settings, requestId, this.fsImpl);
|
|
405
641
|
}
|
|
406
642
|
}
|