@gajae-code/coding-agent 0.11.9 → 0.11.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +8 -1
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +3 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +1 -0
- package/src/cli/notify-cli.ts +1 -0
- package/src/config/model-profiles.ts +7 -7
- package/src/config/model-registry.ts +4 -2
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-draft.ts +2 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
- package/src/gjc-runtime/state-writer.ts +1 -1
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +60 -5
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +667 -118
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +54 -10
- package/src/session/internal/managed-session-scope.ts +133 -117
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +141 -39
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- package/vendor/insane-search/engine/validators.py +4 -1
|
@@ -64,8 +64,10 @@ import {
|
|
|
64
64
|
import type { NotificationSessionStatus } from "../../sdk/bus/session-control";
|
|
65
65
|
import {
|
|
66
66
|
ensureTelegramDaemonRunningDetailed,
|
|
67
|
+
readNotificationRootRegistration,
|
|
67
68
|
resolveTelegramSetupPreflight,
|
|
68
69
|
unregisterNotificationRoot,
|
|
70
|
+
withNotificationRootRegistryFence,
|
|
69
71
|
} from "../../sdk/bus/telegram-daemon";
|
|
70
72
|
import { TelegramDaemonController } from "../../sdk/bus/telegram-daemon-control";
|
|
71
73
|
import { runTelegramSetup, type TelegramSetupPreflight } from "../../sdk/bus/telegram-setup";
|
|
@@ -216,6 +218,12 @@ export interface NotificationsEditorAdapterContext {
|
|
|
216
218
|
notifyConfigChanged?: () => Promise<void> | void;
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
type TelegramDaemonStopResult = {
|
|
222
|
+
ok: boolean;
|
|
223
|
+
message: string;
|
|
224
|
+
before?: { health?: string };
|
|
225
|
+
};
|
|
226
|
+
|
|
219
227
|
export interface NotificationsEditorOperationDependencies {
|
|
220
228
|
getNotificationConfig: typeof getNotificationConfig;
|
|
221
229
|
maskToken: typeof maskToken;
|
|
@@ -231,14 +239,17 @@ export interface NotificationsEditorOperationDependencies {
|
|
|
231
239
|
reconcileCommittedTelegramConfiguration: typeof reconcileCommittedTelegramConfiguration;
|
|
232
240
|
saveTelegramInactive: typeof saveTelegramInactive;
|
|
233
241
|
removeTelegramConfiguration: typeof removeTelegramConfiguration;
|
|
242
|
+
readNotificationRootRegistration: typeof readNotificationRootRegistration;
|
|
234
243
|
unregisterNotificationRoot: typeof unregisterNotificationRoot;
|
|
244
|
+
stopTelegramDaemonIfRootRegistryFenceMatches(input: {
|
|
245
|
+
settings: Settings;
|
|
246
|
+
registryFingerprint: string;
|
|
247
|
+
stop: () => Promise<TelegramDaemonStopResult>;
|
|
248
|
+
}): Promise<boolean>;
|
|
249
|
+
|
|
235
250
|
reloadTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
|
|
236
251
|
restartTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
|
|
237
|
-
stopTelegramDaemon(settings: Settings): Promise<
|
|
238
|
-
ok: boolean;
|
|
239
|
-
message: string;
|
|
240
|
-
before?: { health?: string };
|
|
241
|
-
}>;
|
|
252
|
+
stopTelegramDaemon(settings: Settings): Promise<TelegramDaemonStopResult>;
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
const notificationEditorOperationDependencies: NotificationsEditorOperationDependencies = {
|
|
@@ -256,7 +267,18 @@ const notificationEditorOperationDependencies: NotificationsEditorOperationDepen
|
|
|
256
267
|
reconcileCommittedTelegramConfiguration,
|
|
257
268
|
saveTelegramInactive,
|
|
258
269
|
removeTelegramConfiguration,
|
|
270
|
+
readNotificationRootRegistration,
|
|
259
271
|
unregisterNotificationRoot,
|
|
272
|
+
stopTelegramDaemonIfRootRegistryFenceMatches: async input =>
|
|
273
|
+
await withNotificationRootRegistryFence({
|
|
274
|
+
settings: input.settings,
|
|
275
|
+
registryFingerprint: input.registryFingerprint,
|
|
276
|
+
action: async () => {
|
|
277
|
+
const stopped = await input.stop();
|
|
278
|
+
if (!stopped.ok) throw new Error(stopped.message);
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
|
|
260
282
|
reloadTelegramDaemon: async settings =>
|
|
261
283
|
await new TelegramDaemonController(settings).reload({ spawnIfStopped: false }),
|
|
262
284
|
restartTelegramDaemon: async settings =>
|
|
@@ -625,14 +647,22 @@ export function createNotificationsEditorOperations(
|
|
|
625
647
|
stopAndUnregister: async () => {
|
|
626
648
|
if (controller) await controller.enterBlockedRuntime(sessionContext());
|
|
627
649
|
runtimePrepared = true;
|
|
650
|
+
const registration = await services.readNotificationRootRegistration({
|
|
651
|
+
settings: ctx.settings,
|
|
652
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
653
|
+
});
|
|
628
654
|
const unregistered = await services.unregisterNotificationRoot({
|
|
629
655
|
settings: ctx.settings,
|
|
630
656
|
cwd: ctx.sessionManager.getCwd(),
|
|
631
657
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
658
|
+
registrationToken: registration.token,
|
|
632
659
|
});
|
|
633
|
-
if (unregistered.
|
|
634
|
-
|
|
635
|
-
|
|
660
|
+
if (unregistered.registryFingerprint !== undefined) {
|
|
661
|
+
await services.stopTelegramDaemonIfRootRegistryFenceMatches({
|
|
662
|
+
settings: ctx.settings,
|
|
663
|
+
registryFingerprint: unregistered.registryFingerprint,
|
|
664
|
+
stop: () => services.stopTelegramDaemon(ctx.settings),
|
|
665
|
+
});
|
|
636
666
|
}
|
|
637
667
|
},
|
|
638
668
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`)
|
|
1
|
+
- Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`), GJC workflow state commands (`gjc state ...`), and read-only git inspection (`git status`, `git log`, `git show`, `git diff`, `git blame`, `git rev-parse`, `git ls-files`). Pass artifacts through `GJC_RALPLAN_ARTIFACT` with `--artifact-env GJC_RALPLAN_ARTIFACT`; never use bash for product-source writes, commits, pushes, or other mutating/general shell work.
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: architect
|
|
3
3
|
description: Read-only architecture and code-review agent with severity-rated findings and status verdicts
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
blocking: true
|
|
7
7
|
forkContext: allowed
|
|
8
8
|
bashAllowedPrefixes:
|
|
9
9
|
- gjc ralplan --write
|
|
10
10
|
- gjc state
|
|
11
|
+
- git status
|
|
12
|
+
- git log
|
|
13
|
+
- git show
|
|
14
|
+
- git diff
|
|
15
|
+
- git blame
|
|
16
|
+
- git rev-parse
|
|
17
|
+
- git ls-files
|
|
11
18
|
---
|
|
12
19
|
<identity>
|
|
13
20
|
You are Architect. You combine system architecture review with code-review discipline. Diagnose, analyze, and recommend with file-backed evidence. You are read-only.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: critic
|
|
3
3
|
description: Read-only plan critic that approves only actionable, verifiable execution plans
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- gjc ralplan --write
|
|
8
8
|
- gjc state
|
|
9
|
+
- git status
|
|
10
|
+
- git log
|
|
11
|
+
- git show
|
|
12
|
+
- git diff
|
|
13
|
+
- git blame
|
|
14
|
+
- git rev-parse
|
|
15
|
+
- git ls-files
|
|
9
16
|
---
|
|
10
17
|
<identity>
|
|
11
18
|
You are Critic. Decide whether a work plan is actionable before execution begins.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: planner
|
|
3
3
|
description: Read-only planning agent for sequencing, acceptance criteria, risks, and handoff shape
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
5
5
|
thinking-level: medium
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- gjc ralplan --write
|
|
8
8
|
- gjc state
|
|
9
|
+
- git status
|
|
10
|
+
- git log
|
|
11
|
+
- git show
|
|
12
|
+
- git diff
|
|
13
|
+
- git blame
|
|
14
|
+
- git rev-parse
|
|
15
|
+
- git ls-files
|
|
9
16
|
---
|
|
10
17
|
<identity>
|
|
11
18
|
You are Planner. Turn requests into actionable work plans. You plan; you do not implement.
|
|
@@ -11,20 +11,30 @@ export function brokerIdentityPath(agentDir: string): string {
|
|
|
11
11
|
export async function getBrokerIdentityKey(agentDir: string): Promise<string> {
|
|
12
12
|
const file = brokerIdentityPath(agentDir);
|
|
13
13
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
while (true) {
|
|
15
|
+
try {
|
|
16
|
+
const key = (await fs.readFile(file, "utf8")).trim();
|
|
17
|
+
if (/^[0-9a-f]{64}$/i.test(key)) return key;
|
|
18
|
+
throw new Error(`Invalid broker identity key at ${file}`);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
20
23
|
const key = randomBytes(32).toString("hex");
|
|
24
|
+
const temporary = path.join(path.dirname(file), `.broker.identity-${randomBytes(16).toString("hex")}`);
|
|
21
25
|
try {
|
|
22
|
-
await fs.writeFile(
|
|
23
|
-
await fs.chmod(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
await fs.writeFile(temporary, `${key}\n`, { encoding: "utf8", mode: MODE, flag: "wx" });
|
|
27
|
+
await fs.chmod(temporary, MODE);
|
|
28
|
+
try {
|
|
29
|
+
// Link publishes a fully written key without exposing a partial target.
|
|
30
|
+
// Same-directory hard-link support is required (standard on Linux, macOS, and Windows NTFS).
|
|
31
|
+
await fs.link(temporary, file);
|
|
32
|
+
return key;
|
|
33
|
+
} catch (linkError) {
|
|
34
|
+
if ((linkError as NodeJS.ErrnoException).code !== "EEXIST") throw linkError;
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
await fs.rm(temporary, { force: true });
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
}
|
|
@@ -12,6 +12,7 @@ export interface DaemonPaths {
|
|
|
12
12
|
dir: string;
|
|
13
13
|
lock: string;
|
|
14
14
|
state: string;
|
|
15
|
+
heartbeat: string;
|
|
15
16
|
ownership: string;
|
|
16
17
|
roots: string;
|
|
17
18
|
steal: string;
|
|
@@ -26,6 +27,7 @@ export function daemonPaths(agentDir: string): DaemonPaths {
|
|
|
26
27
|
lock: path.join(dir, "telegram-daemon.lock"),
|
|
27
28
|
state: path.join(dir, "telegram-daemon.state.json"),
|
|
28
29
|
ownership: path.join(dir, "telegram-daemon.ownership"),
|
|
30
|
+
heartbeat: path.join(dir, "telegram-daemon.heartbeat.json"),
|
|
29
31
|
roots: path.join(dir, "telegram-daemon.roots.json"),
|
|
30
32
|
steal: path.join(dir, "telegram-daemon.steal"),
|
|
31
33
|
aliases: path.join(dir, "telegram-callback-aliases.json"),
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -89,6 +89,8 @@ import {
|
|
|
89
89
|
type EnsureDaemonResult,
|
|
90
90
|
endpointAuthorityDigest,
|
|
91
91
|
ensureTelegramDaemonRunningDetailed,
|
|
92
|
+
type RegisterNotificationRootResult,
|
|
93
|
+
unregisterNotificationRoot,
|
|
92
94
|
} from "./telegram-daemon";
|
|
93
95
|
|
|
94
96
|
// ===========================================================================
|
|
@@ -909,6 +911,8 @@ interface SessionRuntime {
|
|
|
909
911
|
hostStopped: boolean;
|
|
910
912
|
serverStopped: boolean;
|
|
911
913
|
brokerRegistrationReleased: boolean;
|
|
914
|
+
/** Managed Telegram root registration released during terminal teardown. */
|
|
915
|
+
notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken?: string };
|
|
912
916
|
verbosity: "lean" | "verbose";
|
|
913
917
|
sessionTag: string;
|
|
914
918
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
@@ -2832,6 +2836,7 @@ export function createNotificationsExtension(
|
|
|
2832
2836
|
settings: Settings;
|
|
2833
2837
|
cwd: string;
|
|
2834
2838
|
sessionId: string;
|
|
2839
|
+
onRegistered?: (registration: RegisterNotificationRootResult) => void;
|
|
2835
2840
|
}) => Promise<EnsureDaemonResult>;
|
|
2836
2841
|
ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
|
|
2837
2842
|
/** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
|
|
@@ -2873,13 +2878,24 @@ export function createNotificationsExtension(
|
|
|
2873
2878
|
settings: Settings,
|
|
2874
2879
|
cwd: string,
|
|
2875
2880
|
id: string,
|
|
2881
|
+
onRegistered?: (registrationToken: string) => void,
|
|
2876
2882
|
): Promise<"ready" | "blocked_identity"> {
|
|
2877
2883
|
if (options.ensureTelegramDaemon) {
|
|
2878
|
-
return (await options.ensureTelegramDaemon({
|
|
2884
|
+
return (await options.ensureTelegramDaemon({
|
|
2885
|
+
settings,
|
|
2886
|
+
cwd,
|
|
2887
|
+
sessionId: id,
|
|
2888
|
+
onRegistered: registration => onRegistered?.(registration.token),
|
|
2889
|
+
})) === "blocked"
|
|
2879
2890
|
? "blocked_identity"
|
|
2880
2891
|
: "ready";
|
|
2881
2892
|
}
|
|
2882
|
-
return (await ensureTelegramDaemonRunningDetailed({
|
|
2893
|
+
return (await ensureTelegramDaemonRunningDetailed({
|
|
2894
|
+
settings,
|
|
2895
|
+
cwd,
|
|
2896
|
+
sessionId: id,
|
|
2897
|
+
onRegistered: registration => onRegistered?.(registration.token),
|
|
2898
|
+
})) === "blocked_identity"
|
|
2883
2899
|
? "blocked_identity"
|
|
2884
2900
|
: "ready";
|
|
2885
2901
|
}
|
|
@@ -2888,9 +2904,10 @@ export function createNotificationsExtension(
|
|
|
2888
2904
|
cfg: NotificationConfig,
|
|
2889
2905
|
cwd: string,
|
|
2890
2906
|
id: string,
|
|
2907
|
+
onRegistered?: (registrationToken: string) => void,
|
|
2891
2908
|
): Promise<boolean> {
|
|
2892
2909
|
if (isTelegramConfigured(cfg)) {
|
|
2893
|
-
if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
|
|
2910
|
+
if ((await ensureTelegramOwner(settings, cwd, id, onRegistered)) === "blocked_identity") return false;
|
|
2894
2911
|
}
|
|
2895
2912
|
await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
|
|
2896
2913
|
return true;
|
|
@@ -3040,6 +3057,20 @@ export function createNotificationsExtension(
|
|
|
3040
3057
|
hostStopped: rt.hostStopped && rt.serverStopped,
|
|
3041
3058
|
brokerRegistrationReleased: rt.brokerRegistrationReleased,
|
|
3042
3059
|
});
|
|
3060
|
+
if (rt.notificationRootRegistration) {
|
|
3061
|
+
try {
|
|
3062
|
+
await unregisterNotificationRoot({
|
|
3063
|
+
settings: rt.notificationRootRegistration.settings,
|
|
3064
|
+
cwd: rt.notificationRootRegistration.cwd,
|
|
3065
|
+
sessionId: id,
|
|
3066
|
+
registrationToken: rt.notificationRootRegistration.registrationToken,
|
|
3067
|
+
});
|
|
3068
|
+
rt.notificationRootRegistration = undefined;
|
|
3069
|
+
} catch (e) {
|
|
3070
|
+
ownerReleaseFailures.push(e);
|
|
3071
|
+
logger.warn(`notifications: Telegram root unregister failed: ${String(e)}`);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3043
3074
|
if (ownerReleaseFailures.length > 0) {
|
|
3044
3075
|
cleanupRetries.set(id, rt);
|
|
3045
3076
|
throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
|
|
@@ -3641,6 +3672,7 @@ export function createNotificationsExtension(
|
|
|
3641
3672
|
inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
|
|
3642
3673
|
deferredGatePresentations: [],
|
|
3643
3674
|
deferredInboundControls: [],
|
|
3675
|
+
notificationRootRegistration: undefined,
|
|
3644
3676
|
};
|
|
3645
3677
|
const initializedRuntime = runtime;
|
|
3646
3678
|
runtimes.set(id, initializedRuntime);
|
|
@@ -4130,12 +4162,19 @@ export function createNotificationsExtension(
|
|
|
4130
4162
|
}
|
|
4131
4163
|
if (notificationsEnabledForSession && settingsAvailable && settings) {
|
|
4132
4164
|
try {
|
|
4133
|
-
|
|
4165
|
+
let registrationToken: string | undefined;
|
|
4166
|
+
if (
|
|
4167
|
+
!(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id, token => {
|
|
4168
|
+
registrationToken = token;
|
|
4169
|
+
}))
|
|
4170
|
+
) {
|
|
4134
4171
|
const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
|
|
4135
4172
|
finishStartup(result);
|
|
4136
4173
|
await cleanupAbandonedStartup();
|
|
4137
4174
|
return result;
|
|
4138
4175
|
}
|
|
4176
|
+
if (isTelegramConfigured(cfg))
|
|
4177
|
+
runtime.notificationRootRegistration = { settings, cwd: ctx.cwd, registrationToken };
|
|
4139
4178
|
} catch (error) {
|
|
4140
4179
|
const result = failLifecycleStartup("failed", error);
|
|
4141
4180
|
finishStartup(result);
|
|
@@ -4434,7 +4473,23 @@ export function createNotificationsExtension(
|
|
|
4434
4473
|
const { settings, settingsAvailable } = resolveSettings(options.settings);
|
|
4435
4474
|
if (!settingsAvailable || !settings) return "blocked_identity";
|
|
4436
4475
|
try {
|
|
4437
|
-
|
|
4476
|
+
let registrationToken: string | undefined;
|
|
4477
|
+
const result = await ensureTelegramOwner(settings, binding.cwd, binding.sessionId, token => {
|
|
4478
|
+
registrationToken = token;
|
|
4479
|
+
});
|
|
4480
|
+
const runtime = runtimes.get(binding.sessionId);
|
|
4481
|
+
const configured = isTelegramConfigured(resolveSettings(options.settings).cfg);
|
|
4482
|
+
if (result === "ready" && runtime && !runtime.stopping && configured) {
|
|
4483
|
+
runtime.notificationRootRegistration = { settings, cwd: binding.cwd, registrationToken };
|
|
4484
|
+
} else if (registrationToken !== undefined) {
|
|
4485
|
+
await unregisterNotificationRoot({
|
|
4486
|
+
settings,
|
|
4487
|
+
cwd: binding.cwd,
|
|
4488
|
+
sessionId: binding.sessionId,
|
|
4489
|
+
registrationToken,
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
return result;
|
|
4438
4493
|
} catch {
|
|
4439
4494
|
return "failed";
|
|
4440
4495
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
tokenFingerprint,
|
|
29
29
|
} from "./config";
|
|
30
30
|
import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
31
|
+
import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon";
|
|
31
32
|
import { DAEMON_GENERATION } from "./telegram-daemon-contract";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_API_BASE = "https://api.telegram.org";
|
|
@@ -578,10 +579,21 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
578
579
|
checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
|
|
579
580
|
}
|
|
580
581
|
|
|
581
|
-
// Daemon ownership state (offline;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
582
|
+
// Daemon ownership state (offline; corrupt or unreadable state degrades to a
|
|
583
|
+
// warning instead of making the health command itself fail).
|
|
584
|
+
let daemonStateUnreadable = false;
|
|
585
|
+
let snapshot: OwnerFreshnessSnapshot;
|
|
586
|
+
try {
|
|
587
|
+
snapshot = await readOwnerFreshnessSnapshot({
|
|
588
|
+
settings: opts.settings,
|
|
589
|
+
fs: fs as unknown as TelegramDaemonFs,
|
|
590
|
+
});
|
|
591
|
+
} catch {
|
|
592
|
+
daemonStateUnreadable = true;
|
|
593
|
+
snapshot = { ownerTag: null, effectiveHeartbeatAt: undefined, legacyEmbedded: false, state: undefined };
|
|
594
|
+
}
|
|
595
|
+
const state = snapshot.state ? parseDaemonState(JSON.stringify(snapshot.state)) : undefined;
|
|
596
|
+
const heartbeatAt = finiteNonNegativeNumber(snapshot.effectiveHeartbeatAt);
|
|
585
597
|
const daemon: DaemonHealth = {
|
|
586
598
|
present: Boolean(state),
|
|
587
599
|
ownerId: state?.ownerId,
|
|
@@ -600,7 +612,9 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
600
612
|
currentGeneration: DAEMON_GENERATION,
|
|
601
613
|
generationRelation: daemonGenerationRelation(state),
|
|
602
614
|
};
|
|
603
|
-
if (
|
|
615
|
+
if (daemonStateUnreadable) {
|
|
616
|
+
checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
|
|
617
|
+
} else if (!state) {
|
|
604
618
|
checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
|
|
605
619
|
} else if (!daemon.alive) {
|
|
606
620
|
checks.push({
|
|
@@ -74,7 +74,7 @@ export class NotificationOperatorRuntime {
|
|
|
74
74
|
#stopRequested = false;
|
|
75
75
|
#activeAbort: AbortController | undefined;
|
|
76
76
|
#intervals = new Map<string, OperatorIntervalHandle>();
|
|
77
|
-
#exclusive = new
|
|
77
|
+
#exclusive = new Map<string, Promise<void>>();
|
|
78
78
|
|
|
79
79
|
#deps: NotificationOperatorTimerDeps;
|
|
80
80
|
|
|
@@ -130,7 +130,7 @@ export class NotificationOperatorRuntime {
|
|
|
130
130
|
|
|
131
131
|
stopInterval(name: string): void {
|
|
132
132
|
const timer = this.#intervals.get(name);
|
|
133
|
-
if (
|
|
133
|
+
if (timer === undefined) return;
|
|
134
134
|
const clearIntervalImpl = this.#deps.clearIntervalImpl ?? clearInterval;
|
|
135
135
|
clearIntervalImpl(timer);
|
|
136
136
|
this.#intervals.delete(name);
|
|
@@ -142,14 +142,40 @@ export class NotificationOperatorRuntime {
|
|
|
142
142
|
|
|
143
143
|
async runExclusive(name: string, fn: () => Promise<void>): Promise<void> {
|
|
144
144
|
if (this.#exclusive.has(name)) return;
|
|
145
|
-
|
|
145
|
+
const completion = Promise.withResolvers<void>();
|
|
146
|
+
this.#exclusive.set(name, completion.promise);
|
|
146
147
|
try {
|
|
147
|
-
|
|
148
|
+
try {
|
|
149
|
+
void fn().then(completion.resolve, completion.reject);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
completion.reject(error);
|
|
152
|
+
}
|
|
153
|
+
await completion.promise;
|
|
148
154
|
} finally {
|
|
149
|
-
this.#exclusive.delete(name);
|
|
155
|
+
if (this.#exclusive.get(name) === completion.promise) this.#exclusive.delete(name);
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
async joinExclusive(name: string, timeoutMs: number): Promise<boolean> {
|
|
160
|
+
const completion = this.#exclusive.get(name);
|
|
161
|
+
if (!completion) return true;
|
|
162
|
+
const setTimeoutImpl = this.#deps.setTimeoutImpl ?? setTimeout;
|
|
163
|
+
const clearTimeoutImpl = this.#deps.clearTimeoutImpl ?? clearTimeout;
|
|
164
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
165
|
+
const timer = setTimeoutImpl(() => resolve(false), timeoutMs);
|
|
166
|
+
void completion.then(
|
|
167
|
+
() => {
|
|
168
|
+
clearTimeoutImpl(timer);
|
|
169
|
+
resolve(true);
|
|
170
|
+
},
|
|
171
|
+
() => {
|
|
172
|
+
clearTimeoutImpl(timer);
|
|
173
|
+
resolve(true);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
return await promise;
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
154
180
|
return new Promise<void>(resolve => {
|
|
155
181
|
if (signal?.aborted) return resolve();
|
|
@@ -21,7 +21,7 @@ import * as fs from "node:fs";
|
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
import { getAgentDir } from "@gajae-code/utils";
|
|
23
23
|
import { tokenFingerprint } from "./config";
|
|
24
|
-
import {
|
|
24
|
+
import { isFreshLiveOwner, readOwnerFreshnessSnapshot } from "./telegram-daemon";
|
|
25
25
|
import { runTelegramReferenceClient } from "./telegram-reference";
|
|
26
26
|
|
|
27
27
|
interface CliArgs {
|
|
@@ -134,17 +134,9 @@ function pidAlive(pid: number): boolean {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return JSON.parse(raw) as DaemonState;
|
|
141
|
-
} catch {
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): boolean {
|
|
147
|
-
const state = readDaemonState();
|
|
137
|
+
async function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): Promise<boolean> {
|
|
138
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: { getAgentDir } as never });
|
|
139
|
+
const state = snapshot.state;
|
|
148
140
|
if (!state) return false;
|
|
149
141
|
return isFreshLiveOwner({
|
|
150
142
|
state,
|
|
@@ -152,6 +144,7 @@ function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): boo
|
|
|
152
144
|
tokenFingerprint: tokenFingerprint(input.botToken),
|
|
153
145
|
chatId: input.chatId,
|
|
154
146
|
pidAlive,
|
|
147
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
155
148
|
});
|
|
156
149
|
}
|
|
157
150
|
|
|
@@ -176,7 +169,7 @@ async function main(): Promise<void> {
|
|
|
176
169
|
}
|
|
177
170
|
|
|
178
171
|
const chatId = args.chatId ?? process.env.GJC_TG_CHAT_ID ?? (await resolveChatId(botToken, apiBase));
|
|
179
|
-
if (!args.force && activeDaemonOwnsToken({ botToken, chatId })) {
|
|
172
|
+
if (!args.force && (await activeDaemonOwnsToken({ botToken, chatId }))) {
|
|
180
173
|
process.stderr.write(
|
|
181
174
|
"an active gjc notifications daemon already owns this bot token; running a second poller will cause Telegram 409 conflicts. Re-run with --force to override.\n",
|
|
182
175
|
);
|
|
@@ -14,6 +14,7 @@ import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
|
14
14
|
import {
|
|
15
15
|
type DaemonState,
|
|
16
16
|
readDaemonState,
|
|
17
|
+
readOwnerFreshnessSnapshot,
|
|
17
18
|
type TelegramDaemonOptions,
|
|
18
19
|
TelegramNotificationDaemon,
|
|
19
20
|
} from "./telegram-daemon";
|
|
@@ -254,15 +255,19 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
254
255
|
if (!watchdogActive || watchdogTickInFlight || stopRequested) return;
|
|
255
256
|
watchdogTickInFlight = true;
|
|
256
257
|
try {
|
|
257
|
-
const
|
|
258
|
+
const snapshot = deps.readDaemonState
|
|
259
|
+
? { state: await readState(settings as Settings), effectiveHeartbeatAt: undefined }
|
|
260
|
+
: await readOwnerFreshnessSnapshot({ settings: settings as Settings });
|
|
261
|
+
const state = snapshot.state;
|
|
262
|
+
const heartbeatAt = snapshot.effectiveHeartbeatAt ?? state?.heartbeatAt;
|
|
258
263
|
if (!watchdogActive || !state) return;
|
|
259
264
|
if (state.ownerId !== ownerId) {
|
|
260
265
|
stopRequested = true;
|
|
261
266
|
daemon.requestStop("stop");
|
|
262
267
|
return;
|
|
263
268
|
}
|
|
264
|
-
if (lastHeartbeatAt === undefined ||
|
|
265
|
-
lastHeartbeatAt =
|
|
269
|
+
if (lastHeartbeatAt === undefined || heartbeatAt !== lastHeartbeatAt) {
|
|
270
|
+
lastHeartbeatAt = heartbeatAt;
|
|
266
271
|
stalledSince = now();
|
|
267
272
|
return;
|
|
268
273
|
}
|
|
@@ -7,12 +7,9 @@
|
|
|
7
7
|
export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* freshly-upgraded host must still recognize an older, still-live daemon that
|
|
14
|
-
* predates capability-gated frame enforcement and trigger a reload. Bump this
|
|
15
|
-
* on every daemon-behavior change independent of the wire version.
|
|
10
|
+
* Guarded behavior-inventory version for the current daemon build. Bump this
|
|
11
|
+
* on every guarded daemon-behavior change independent of the wire version; it
|
|
12
|
+
* does not force a live daemon reload by itself.
|
|
16
13
|
* The current development baseline already includes #2299's generation 4,
|
|
17
14
|
* incarnation fencing in generation 5, owner-lock authority in generation 6,
|
|
18
15
|
* identity-atomic transition markers in generation 7, stable signaling plus
|
|
@@ -40,5 +37,14 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
|
40
37
|
* cooldown and lazy Telegram topic lifecycle safeguards (#2956, #2960, #2984).
|
|
41
38
|
* Generation 27 refreshes retained native path and process authority semantics.
|
|
42
39
|
* Generation 28 rejects special files before retained native authority opens.
|
|
40
|
+
* Generation 29 adds serving-epoch compatibility, sidecar heartbeat, root GC,
|
|
41
|
+
* and Bot API cooldown structural fixes (#2956, #2960, #3048).
|
|
43
42
|
*/
|
|
44
|
-
export const DAEMON_GENERATION =
|
|
43
|
+
export const DAEMON_GENERATION = 29;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers
|
|
47
|
+
* all builds published before this field existed; bump this to force serving
|
|
48
|
+
* convergence and reload of compatible live predecessors.
|
|
49
|
+
*/
|
|
50
|
+
export const SERVING_EPOCH = 2;
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
readAttestedLegacyDaemonOwner,
|
|
41
41
|
readDaemonRoots,
|
|
42
42
|
readDaemonState,
|
|
43
|
+
readOwnerFreshnessSnapshot,
|
|
43
44
|
spawnTelegramDaemonOwner,
|
|
44
45
|
type TelegramDaemonDeps,
|
|
45
46
|
type TelegramDaemonFs,
|
|
@@ -224,7 +225,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
224
225
|
if (!configured) {
|
|
225
226
|
return { kind: this.kind, configured: false, health: "not_configured", runtime };
|
|
226
227
|
}
|
|
227
|
-
const
|
|
228
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
|
|
229
|
+
const state = snapshot.state;
|
|
228
230
|
const roots = await readDaemonRoots(this.settings, this.fsImpl);
|
|
229
231
|
const health: DaemonHealth =
|
|
230
232
|
!state || state.stoppedAt !== undefined || !this.pidAlive(state.pid)
|
|
@@ -236,6 +238,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
236
238
|
chatId: cfg.chatId as string,
|
|
237
239
|
pidAlive: this.pidAlive,
|
|
238
240
|
pidIncarnation: this.deps.pidIncarnation,
|
|
241
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
239
242
|
})
|
|
240
243
|
? "running"
|
|
241
244
|
: "stale";
|
|
@@ -246,7 +249,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
246
249
|
pid: state?.pid,
|
|
247
250
|
ownerId: state?.ownerId,
|
|
248
251
|
startedAt: state?.startedAt,
|
|
249
|
-
heartbeatAt:
|
|
252
|
+
heartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
250
253
|
roots,
|
|
251
254
|
rootCount: roots.length,
|
|
252
255
|
runtime,
|
|
@@ -480,7 +483,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
480
483
|
const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS;
|
|
481
484
|
const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
|
482
485
|
|
|
483
|
-
const
|
|
486
|
+
const ownerSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
|
|
487
|
+
const state = ownerSnapshot.state;
|
|
484
488
|
const attestedLegacyOwner = attestedLegacyUpgrade
|
|
485
489
|
? await readAttestedLegacyDaemonOwner({
|
|
486
490
|
settings: this.settings,
|
|
@@ -501,6 +505,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
501
505
|
chatId,
|
|
502
506
|
pidAlive: this.pidAlive,
|
|
503
507
|
pidIncarnation: this.deps.pidIncarnation,
|
|
508
|
+
effectiveHeartbeatAt: ownerSnapshot.effectiveHeartbeatAt,
|
|
504
509
|
}) &&
|
|
505
510
|
isSignalableMatchingOwner({
|
|
506
511
|
state,
|
|
@@ -628,7 +633,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
628
633
|
let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
|
|
629
634
|
if (!dead) {
|
|
630
635
|
// Old pid still alive after the cooperative SIGTERM. Inspect current ownership.
|
|
631
|
-
const
|
|
636
|
+
const currentSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
|
|
637
|
+
const current = currentSnapshot.state;
|
|
632
638
|
const changedToLiveOwner =
|
|
633
639
|
current !== undefined &&
|
|
634
640
|
current.ownerId !== oldOwnerId &&
|
|
@@ -639,6 +645,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
639
645
|
chatId,
|
|
640
646
|
pidAlive: this.pidAlive,
|
|
641
647
|
pidIncarnation: this.deps.pidIncarnation,
|
|
648
|
+
effectiveHeartbeatAt: currentSnapshot.effectiveHeartbeatAt,
|
|
642
649
|
});
|
|
643
650
|
if (changedToLiveOwner) {
|
|
644
651
|
await this.clearOwnRequest(requestId);
|
|
@@ -701,7 +708,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
701
708
|
warnings.push(...spawned.warnings);
|
|
702
709
|
const after = await this.status();
|
|
703
710
|
if (spawned.result === "attached") {
|
|
704
|
-
const
|
|
711
|
+
const attachedSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
|
|
712
|
+
const attachedState = attachedSnapshot.state;
|
|
705
713
|
if (
|
|
706
714
|
!isCurrentCompatibleOwner({
|
|
707
715
|
state: attachedState,
|
|
@@ -710,6 +718,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
710
718
|
chatId,
|
|
711
719
|
pidAlive: this.pidAlive,
|
|
712
720
|
pidIncarnation: this.deps.pidIncarnation,
|
|
721
|
+
effectiveHeartbeatAt: attachedSnapshot.effectiveHeartbeatAt,
|
|
713
722
|
})
|
|
714
723
|
) {
|
|
715
724
|
return this.result(
|