@gajae-code/coding-agent 0.12.5 → 0.12.7
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 +34 -0
- package/README.md +4 -1
- package/dist/types/async/job-manager.d.ts +36 -1
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +43 -0
- package/dist/types/tools/subagent.d.ts +15 -0
- package/dist/types/tools/todo-write.d.ts +3 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/async/job-manager.ts +430 -46
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/modes/acp/acp-agent.ts +27 -13
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +691 -92
- package/src/sdk/bus/chat-daemon-control.ts +30 -6
- package/src/sdk/bus/index.ts +23 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/session.ts +7 -6
- package/src/session/agent-session.ts +752 -339
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/internal/managed-session-scope.ts +252 -67
- package/src/session/internal/managed-session-storage.ts +1 -0
- package/src/session/session-manager.ts +7 -0
- package/src/session/session-storage.ts +146 -24
- package/src/task/index.ts +230 -19
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +2 -0
- package/src/task/types.ts +10 -0
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +10 -2
- package/src/tools/subagent.ts +97 -75
- package/src/tools/todo-write.ts +47 -0
|
@@ -39,13 +39,12 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
39
39
|
* applies the current notification configuration directly when starting chat
|
|
40
40
|
* daemon transports. Generation 16 applies Telegram sound-policy configuration
|
|
41
41
|
* through shared notification parsing. Generation 17 bound managed-session
|
|
42
|
-
* replacement to exact native filesystem authority; generation 18
|
|
43
|
-
* binding
|
|
44
|
-
* the lifecycle contract moving strictly forward.
|
|
42
|
+
* replacement to exact native filesystem authority; generation 18 retired that
|
|
43
|
+
* binding, and generation 19 binds exact cleanup to parent/link-count authority.
|
|
45
44
|
*/
|
|
46
45
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
47
|
-
discord:
|
|
48
|
-
slack:
|
|
46
|
+
discord: 20,
|
|
47
|
+
slack: 20,
|
|
49
48
|
};
|
|
50
49
|
|
|
51
50
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
@@ -245,6 +244,9 @@ interface ChatDaemonOwnerLockLease {
|
|
|
245
244
|
ino: bigint;
|
|
246
245
|
size: bigint;
|
|
247
246
|
mtimeNs: bigint;
|
|
247
|
+
nlink: bigint;
|
|
248
|
+
parentDev: bigint;
|
|
249
|
+
parentIno: bigint;
|
|
248
250
|
sha256: string;
|
|
249
251
|
}
|
|
250
252
|
|
|
@@ -892,6 +894,7 @@ async function createChatDaemonOwnerLock(
|
|
|
892
894
|
if (isAlreadyExists(error)) return undefined;
|
|
893
895
|
throw error;
|
|
894
896
|
}
|
|
897
|
+
await fs.promises.unlink(temporary);
|
|
895
898
|
return await captureChatDaemonOwnerLockLease(lock);
|
|
896
899
|
} finally {
|
|
897
900
|
await fs.promises.unlink(temporary).catch(() => undefined);
|
|
@@ -903,14 +906,22 @@ async function captureChatDaemonOwnerLockLease(lock: string): Promise<ChatDaemon
|
|
|
903
906
|
const handle = await fs.promises.open(lock, "r");
|
|
904
907
|
try {
|
|
905
908
|
const before = await handle.stat({ bigint: true });
|
|
909
|
+
const parentBefore = await fs.promises.lstat(path.dirname(lock), { bigint: true });
|
|
906
910
|
if (!before.isFile()) return undefined;
|
|
907
911
|
const content = await handle.readFile({ encoding: "utf8" });
|
|
908
912
|
const after = await handle.stat({ bigint: true });
|
|
909
913
|
const pathname = await fs.promises.lstat(lock, { bigint: true });
|
|
914
|
+
const parentAfter = await fs.promises.lstat(path.dirname(lock), { bigint: true });
|
|
910
915
|
if (
|
|
911
916
|
!after.isFile() ||
|
|
912
917
|
!pathname.isFile() ||
|
|
913
918
|
pathname.isSymbolicLink() ||
|
|
919
|
+
before.nlink !== 1n ||
|
|
920
|
+
pathname.nlink !== 1n ||
|
|
921
|
+
!parentBefore.isDirectory() ||
|
|
922
|
+
parentBefore.isSymbolicLink() ||
|
|
923
|
+
parentBefore.dev !== parentAfter.dev ||
|
|
924
|
+
parentBefore.ino !== parentAfter.ino ||
|
|
914
925
|
before.dev !== after.dev ||
|
|
915
926
|
before.ino !== after.ino ||
|
|
916
927
|
before.size !== after.size ||
|
|
@@ -927,6 +938,9 @@ async function captureChatDaemonOwnerLockLease(lock: string): Promise<ChatDaemon
|
|
|
927
938
|
ino: before.ino,
|
|
928
939
|
size: before.size,
|
|
929
940
|
mtimeNs: before.mtimeNs,
|
|
941
|
+
nlink: before.nlink,
|
|
942
|
+
parentDev: parentBefore.dev,
|
|
943
|
+
parentIno: parentBefore.ino,
|
|
930
944
|
sha256: crypto.createHash("sha256").update(content).digest("hex"),
|
|
931
945
|
};
|
|
932
946
|
} finally {
|
|
@@ -944,6 +958,9 @@ async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockL
|
|
|
944
958
|
current.ino === lease.ino &&
|
|
945
959
|
current.size === lease.size &&
|
|
946
960
|
current.mtimeNs === lease.mtimeNs &&
|
|
961
|
+
current.nlink === lease.nlink &&
|
|
962
|
+
current.parentDev === lease.parentDev &&
|
|
963
|
+
current.parentIno === lease.parentIno &&
|
|
947
964
|
current.content === lease.content
|
|
948
965
|
);
|
|
949
966
|
}
|
|
@@ -952,7 +969,14 @@ async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockL
|
|
|
952
969
|
function unlinkExactChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): boolean {
|
|
953
970
|
try {
|
|
954
971
|
const removed = native.exactUnlink(lock, {
|
|
955
|
-
|
|
972
|
+
dev: lease.dev,
|
|
973
|
+
ino: lease.ino,
|
|
974
|
+
size: lease.size,
|
|
975
|
+
mtimeNs: lease.mtimeNs,
|
|
976
|
+
nlink: lease.nlink,
|
|
977
|
+
parentDev: lease.parentDev,
|
|
978
|
+
parentIno: lease.parentIno,
|
|
979
|
+
sha256: lease.sha256,
|
|
956
980
|
quarantineName: `.gjc-delete-chat-daemon-lock-${crypto.randomUUID()}`,
|
|
957
981
|
});
|
|
958
982
|
if (removed.ok) return true;
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import * as fs from "node:fs";
|
|
|
27
27
|
import * as os from "node:os";
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
import { promisify } from "node:util";
|
|
30
|
-
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
30
|
+
import { type RunSettlementProof, ThinkingLevel } from "@gajae-code/agent-core";
|
|
31
31
|
import type { ImageContent, TextContent, Tool } from "@gajae-code/ai";
|
|
32
32
|
import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
|
|
33
33
|
import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
|
|
@@ -116,6 +116,25 @@ export {
|
|
|
116
116
|
runIdentityControlTerminalPath,
|
|
117
117
|
} from "./control-drain-lease";
|
|
118
118
|
|
|
119
|
+
const PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT = 8;
|
|
120
|
+
const PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS = 86_400_000;
|
|
121
|
+
|
|
122
|
+
export function formatPromptSettlementDiagnostic(
|
|
123
|
+
proof: Extract<RunSettlementProof, { status: "unfenced" }>,
|
|
124
|
+
now = Date.now(),
|
|
125
|
+
): string {
|
|
126
|
+
const pending = proof.pending.slice(0, PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT).map(entry => ({
|
|
127
|
+
kind: entry.kind,
|
|
128
|
+
labelHash: crypto.createHash("sha256").update(entry.label).digest("hex").slice(0, 16),
|
|
129
|
+
ageMs: Math.max(0, Math.min(PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS, now - entry.registeredAt)),
|
|
130
|
+
}));
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
reason: proof.reason,
|
|
133
|
+
pending,
|
|
134
|
+
omitted: Math.max(0, proof.pending.length - pending.length),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
119
138
|
// ===========================================================================
|
|
120
139
|
// Session lifecycle control protocol (TypeScript mirror of the Rust wire
|
|
121
140
|
// contract in `crates/gjc-sdk/src/lifecycle.rs`).
|
|
@@ -3990,10 +4009,7 @@ export function createNotificationsExtension(
|
|
|
3990
4009
|
submission.phase = "terminalizing";
|
|
3991
4010
|
if (options.fence) {
|
|
3992
4011
|
const seam = ctx as typeof ctx & {
|
|
3993
|
-
abortPromptAndWait?: (
|
|
3994
|
-
handle: string,
|
|
3995
|
-
options: { graceMs: number },
|
|
3996
|
-
) => Promise<{ status: "settled" } | { status: "unfenced" }>;
|
|
4012
|
+
abortPromptAndWait?: (handle: string, options: { graceMs: number }) => Promise<RunSettlementProof>;
|
|
3997
4013
|
};
|
|
3998
4014
|
// Only the handle captured for this correlation may be fenced; a later run
|
|
3999
4015
|
// must never be aborted by an older prompt's cleanup.
|
|
@@ -4006,7 +4022,7 @@ export function createNotificationsExtension(
|
|
|
4006
4022
|
);
|
|
4007
4023
|
return;
|
|
4008
4024
|
}
|
|
4009
|
-
let proof:
|
|
4025
|
+
let proof: RunSettlementProof;
|
|
4010
4026
|
try {
|
|
4011
4027
|
proof = await seam.abortPromptAndWait(submission.executionHandle, {
|
|
4012
4028
|
graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
|
|
@@ -4022,6 +4038,7 @@ export function createNotificationsExtension(
|
|
|
4022
4038
|
return;
|
|
4023
4039
|
}
|
|
4024
4040
|
if (proof?.status !== "settled") {
|
|
4041
|
+
logger.warn(`sdk: prompt resource settlement unfenced: ${formatPromptSettlementDiagnostic(proof)}`);
|
|
4025
4042
|
// No settlement proof: never publish a normal terminal. The durable
|
|
4026
4043
|
// pending claim stays active so restart recovery finalizes it.
|
|
4027
4044
|
failPromptClosed(
|
|
@@ -49,9 +49,9 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
|
49
49
|
* user-created topic adoption (forum-topic folder picker). Generation 36 bound
|
|
50
50
|
* managed-session replacement to exact native filesystem authority; generation
|
|
51
51
|
* 37 retires that binding (revert of #3489, which stalled POSIX artifact
|
|
52
|
-
* cleanup)
|
|
52
|
+
* cleanup); generation 38 binds exact cleanup to parent and link-count authority.
|
|
53
53
|
*/
|
|
54
|
-
export const DAEMON_GENERATION =
|
|
54
|
+
export const DAEMON_GENERATION = 38;
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers
|
|
@@ -160,11 +160,17 @@ export interface TelegramDaemonFs {
|
|
|
160
160
|
size?: number;
|
|
161
161
|
dev?: number;
|
|
162
162
|
ino?: number;
|
|
163
|
+
/** Hard-link count; required to prove a staging temp has no second name. */
|
|
164
|
+
nlink?: number;
|
|
163
165
|
ctimeMs?: number;
|
|
164
166
|
isDirectory?: () => boolean;
|
|
165
167
|
}>;
|
|
166
168
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
167
|
-
exactUnlink?(
|
|
169
|
+
exactUnlink?(
|
|
170
|
+
path: string,
|
|
171
|
+
identity: NotificationEndpointFileIdentity,
|
|
172
|
+
quarantineName?: string,
|
|
173
|
+
): Promise<NotificationExactUnlinkResult>;
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
export interface SpawnResult {
|
|
@@ -228,8 +234,12 @@ function negotiateToolActivityCapability(
|
|
|
228
234
|
const nodeFs: TelegramDaemonFs = {
|
|
229
235
|
...(fs.promises as unknown as TelegramDaemonFs),
|
|
230
236
|
readEndpointFile: readNotificationEndpointFile,
|
|
231
|
-
exactUnlink: async (file, identity) =>
|
|
232
|
-
exactUnlinkNotificationFile(
|
|
237
|
+
exactUnlink: async (file, identity, quarantineName) =>
|
|
238
|
+
exactUnlinkNotificationFile(
|
|
239
|
+
file,
|
|
240
|
+
identity,
|
|
241
|
+
quarantineName ?? `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`,
|
|
242
|
+
),
|
|
233
243
|
};
|
|
234
244
|
|
|
235
245
|
/**
|
|
@@ -935,8 +945,9 @@ async function exactUnlinkAcceptedWithRetainedEvidence(
|
|
|
935
945
|
fsImpl: TelegramDaemonFs,
|
|
936
946
|
file: string,
|
|
937
947
|
identity: NotificationEndpointFileIdentity,
|
|
948
|
+
quarantineName?: string,
|
|
938
949
|
): Promise<boolean> {
|
|
939
|
-
const removed = await fsImpl.exactUnlink!(file, identity);
|
|
950
|
+
const removed = await fsImpl.exactUnlink!(file, identity, quarantineName);
|
|
940
951
|
if (removed.ok) return true;
|
|
941
952
|
return (
|
|
942
953
|
removed.code === "cleanup_pending" &&
|
|
@@ -946,6 +957,27 @@ async function exactUnlinkAcceptedWithRetainedEvidence(
|
|
|
946
957
|
);
|
|
947
958
|
}
|
|
948
959
|
|
|
960
|
+
type NotificationArtifactReapOutcome = "removed" | "retained" | "unchanged";
|
|
961
|
+
|
|
962
|
+
/** Distinguish terminal removal from a safely detached artifact that still needs authority-preserving cleanup. */
|
|
963
|
+
async function reapNotificationArtifactExactly(
|
|
964
|
+
fsImpl: TelegramDaemonFs,
|
|
965
|
+
file: string,
|
|
966
|
+
identity: NotificationEndpointFileIdentity,
|
|
967
|
+
quarantineName: string,
|
|
968
|
+
): Promise<NotificationArtifactReapOutcome> {
|
|
969
|
+
const removed = await fsImpl.exactUnlink!(file, identity, quarantineName);
|
|
970
|
+
if (removed.ok) return "removed";
|
|
971
|
+
if (
|
|
972
|
+
removed.code === "cleanup_pending" &&
|
|
973
|
+
typeof removed.detachedPath === "string" &&
|
|
974
|
+
removed.detachedPath.length > 0 &&
|
|
975
|
+
(await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
|
|
976
|
+
)
|
|
977
|
+
return "retained";
|
|
978
|
+
return "unchanged";
|
|
979
|
+
}
|
|
980
|
+
|
|
949
981
|
async function unlinkOwnershipLockExactly(
|
|
950
982
|
fsImpl: TelegramDaemonFs,
|
|
951
983
|
file: string,
|
|
@@ -1287,6 +1319,7 @@ export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
|
|
|
1287
1319
|
".gjc-delete-daemon-transition-",
|
|
1288
1320
|
".gjc-exact-unlink-placeholder-",
|
|
1289
1321
|
".gjc-delete-notification-endpoint-",
|
|
1322
|
+
".gjc-delete-notification-staging-temp-",
|
|
1290
1323
|
] as const;
|
|
1291
1324
|
|
|
1292
1325
|
/** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
|
|
@@ -1298,6 +1331,80 @@ export function isPermanentMissingPathError(error: unknown): boolean {
|
|
|
1298
1331
|
return code === "ENOENT" || code === "ENOTDIR";
|
|
1299
1332
|
}
|
|
1300
1333
|
|
|
1334
|
+
/**
|
|
1335
|
+
* Shape of an abandoned `writeJsonAtomic` staging file:
|
|
1336
|
+
* `<published-name>.<pid>.<epoch-ms>.<base36-suffix>.tmp`.
|
|
1337
|
+
*
|
|
1338
|
+
* Publication stages a sibling temp and then renames it over the destination.
|
|
1339
|
+
* If the staging write or the rename fails, or the process dies between the
|
|
1340
|
+
* two, that temp is never published and never read again — no prefix in
|
|
1341
|
+
* {@link NOTIFICATION_LEAK_ARTIFACT_PREFIXES} claimed it, so it accumulated in
|
|
1342
|
+
* the agent notifications directory permanently, once per failed attempt.
|
|
1343
|
+
*
|
|
1344
|
+
* Reaping it here (rather than only unwinding in the writer) also reclaims
|
|
1345
|
+
* temps orphaned by a crash or power loss, which no writer-side cleanup can
|
|
1346
|
+
* reach. Age alone is not proof of abandonment, though: a slow or blocked
|
|
1347
|
+
* publisher (a stalled network write, a rename fenced by an antivirus handle)
|
|
1348
|
+
* can hold a live staged temp well past any grace window. The staged name
|
|
1349
|
+
* therefore carries its publisher's PID, and the reaper only removes a temp
|
|
1350
|
+
* whose publisher is provably dead — see {@link parseNotificationStagingTemp}.
|
|
1351
|
+
*/
|
|
1352
|
+
const NOTIFICATION_STAGING_TEMP_PATTERN = /^(?<destination>.+)\.(?<pid>\d+)\.(?<stagedAt>\d+)\.[0-9a-z]+\.tmp$/;
|
|
1353
|
+
|
|
1354
|
+
/** A publication staging claim recovered from an abandoned temp's name. */
|
|
1355
|
+
export interface NotificationStagingTempClaim {
|
|
1356
|
+
/** Published sibling this temp was staged for. */
|
|
1357
|
+
destination: string;
|
|
1358
|
+
/** PID of the process that staged it. */
|
|
1359
|
+
pid: number;
|
|
1360
|
+
/** Wall-clock ms the publisher recorded when it staged the temp. */
|
|
1361
|
+
stagedAtMs: number;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Recover the publication claim encoded in a staging temp's name, or
|
|
1366
|
+
* `undefined` when `name` is not a staging temp or its PID/timestamp fields are
|
|
1367
|
+
* not usable integers. An unparseable claim is never reaped.
|
|
1368
|
+
*/
|
|
1369
|
+
export function parseNotificationStagingTemp(name: string): NotificationStagingTempClaim | undefined {
|
|
1370
|
+
const groups = NOTIFICATION_STAGING_TEMP_PATTERN.exec(name)?.groups;
|
|
1371
|
+
if (!groups) return undefined;
|
|
1372
|
+
const pid = Number(groups.pid);
|
|
1373
|
+
const stagedAtMs = Number(groups.stagedAt);
|
|
1374
|
+
if (!validDaemonPid(pid) || !Number.isSafeInteger(stagedAtMs) || stagedAtMs < 0) return undefined;
|
|
1375
|
+
return { destination: groups.destination as string, pid, stagedAtMs };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** Liveness verdict for a staging temp's publisher; `unknown` fails closed. */
|
|
1379
|
+
type NotificationPublisherLiveness = "alive" | "dead" | "unknown";
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Classify a staging temp publisher against the daemon's liveness seam. A
|
|
1383
|
+
* throwing probe (an unreadable process table, a denied query) is
|
|
1384
|
+
* indeterminate, not dead, so the temp is retained.
|
|
1385
|
+
*/
|
|
1386
|
+
function classifyNotificationStagingPublisher(
|
|
1387
|
+
claim: NotificationStagingTempClaim,
|
|
1388
|
+
pidAlive: (pid: number) => boolean,
|
|
1389
|
+
): NotificationPublisherLiveness {
|
|
1390
|
+
try {
|
|
1391
|
+
return pidAlive(claim.pid) ? "alive" : "dead";
|
|
1392
|
+
} catch {
|
|
1393
|
+
return "unknown";
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* True when `file` is a regular file reachable under exactly one name. A
|
|
1399
|
+
* multi-link file shares its inode with another pathname, so unlinking this one
|
|
1400
|
+
* would not reclaim the data and may be another owner's live hardlink. Fails
|
|
1401
|
+
* closed when the `stat` seam cannot report a link count.
|
|
1402
|
+
*/
|
|
1403
|
+
async function isSingleLinkRegularFile(fsImpl: TelegramDaemonFs, file: string): Promise<boolean> {
|
|
1404
|
+
if (!fsImpl.stat) return false;
|
|
1405
|
+
const stat = await fsImpl.stat(file);
|
|
1406
|
+
return stat.nlink === 1;
|
|
1407
|
+
}
|
|
1301
1408
|
export function isNotificationLeakArtifactName(name: string): boolean {
|
|
1302
1409
|
return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
1303
1410
|
}
|
|
@@ -1408,21 +1515,89 @@ export async function pruneMissingNotificationRoots(input: {
|
|
|
1408
1515
|
return { pruned, remaining };
|
|
1409
1516
|
}
|
|
1410
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* Remove one abandoned publication staging temp under a liveness fence and an
|
|
1520
|
+
* identity-bound delete. Returns whether the temp was removed; `false` means it
|
|
1521
|
+
* was deliberately retained (live/indeterminate publisher, not a single-link
|
|
1522
|
+
* regular file, still inside the grace window, or an identity change between
|
|
1523
|
+
* capture and delete). Filesystem faults propagate to the caller's best-effort
|
|
1524
|
+
* handler.
|
|
1525
|
+
*/
|
|
1526
|
+
async function reapAbandonedNotificationStagingTemp(input: {
|
|
1527
|
+
fs: TelegramDaemonFs;
|
|
1528
|
+
file: string;
|
|
1529
|
+
claim: NotificationStagingTempClaim;
|
|
1530
|
+
now: number;
|
|
1531
|
+
graceMs: number;
|
|
1532
|
+
pidAlive: (pid: number) => boolean;
|
|
1533
|
+
}): Promise<NotificationArtifactReapOutcome> {
|
|
1534
|
+
// A live or blocked publisher can hold a staged temp far past any grace
|
|
1535
|
+
// window; only a provably dead publisher's claim is abandoned. `unknown`
|
|
1536
|
+
// (throwing probe) fails closed.
|
|
1537
|
+
if (classifyNotificationStagingPublisher(input.claim, input.pidAlive) !== "dead") return "unchanged";
|
|
1538
|
+
const readEndpointFile = input.fs.readEndpointFile;
|
|
1539
|
+
// Both seams are optional; without them there is no no-follow capture and no
|
|
1540
|
+
// identity-bound delete, so retain rather than unlink unfenced.
|
|
1541
|
+
if (!readEndpointFile || !input.fs.exactUnlink) return "unchanged";
|
|
1542
|
+
// No-follow capture: rejects symlinks, directories, and anything that changes
|
|
1543
|
+
// while it is read. A reparse point or dangling link is therefore retained.
|
|
1544
|
+
const endpoint = await readEndpointFile(input.file);
|
|
1545
|
+
if (!(await isSingleLinkRegularFile(input.fs, input.file))) return "unchanged";
|
|
1546
|
+
// Age from the captured (no-follow) mtime rather than a second path-following
|
|
1547
|
+
// stat, so the grace decision and the delete bind the same inode. Integer ns
|
|
1548
|
+
// truncation only ever ages the file, never rejuvenates it.
|
|
1549
|
+
const age = input.now - Number(endpoint.identity.mtimeNs / 1_000_000n);
|
|
1550
|
+
if (age < input.graceMs) return "unchanged";
|
|
1551
|
+
// The native verifies dev+ino+size+mtimeNs+sha256 before unlinking, so a
|
|
1552
|
+
// readdir/capture -> replacement ABA cannot delete the fresh generation.
|
|
1553
|
+
return await reapNotificationArtifactExactly(
|
|
1554
|
+
input.fs,
|
|
1555
|
+
input.file,
|
|
1556
|
+
endpoint.identity,
|
|
1557
|
+
`.gjc-delete-notification-staging-temp-${crypto.randomUUID()}.json`,
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** Reap one retained delete quarantine without following or unlinking a successor. */
|
|
1562
|
+
async function reapNotificationLeakArtifact(input: {
|
|
1563
|
+
fs: TelegramDaemonFs;
|
|
1564
|
+
file: string;
|
|
1565
|
+
now: number;
|
|
1566
|
+
graceMs: number;
|
|
1567
|
+
}): Promise<NotificationArtifactReapOutcome> {
|
|
1568
|
+
if (path.basename(input.file).startsWith(".gjc-exact-unlink-placeholder-")) return "retained";
|
|
1569
|
+
if (!input.fs.readEndpointFile || !input.fs.exactUnlink) return "unchanged";
|
|
1570
|
+
const endpoint = await input.fs.readEndpointFile(input.file);
|
|
1571
|
+
if (!(await isSingleLinkRegularFile(input.fs, input.file))) return "unchanged";
|
|
1572
|
+
const age = input.now - Number(endpoint.identity.mtimeNs / 1_000_000n);
|
|
1573
|
+
if (age < input.graceMs) return "unchanged";
|
|
1574
|
+
return await reapNotificationArtifactExactly(
|
|
1575
|
+
input.fs,
|
|
1576
|
+
input.file,
|
|
1577
|
+
endpoint.identity,
|
|
1578
|
+
`.gjc-exact-unlink-placeholder-${crypto.randomUUID()}.json`,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1411
1582
|
/**
|
|
1412
1583
|
* Reap retained exact-unlink / ownership-transition quarantine files older than
|
|
1413
|
-
* the grace window from the notifications directory
|
|
1584
|
+
* the grace window from the notifications directory, plus publication staging
|
|
1585
|
+
* temps whose publisher is provably dead.
|
|
1414
1586
|
*/
|
|
1415
1587
|
export async function reapStaleNotificationArtifacts(input: {
|
|
1416
1588
|
settings: Settings;
|
|
1417
1589
|
fs?: TelegramDaemonFs;
|
|
1418
1590
|
now?: () => number;
|
|
1419
1591
|
graceMs?: number;
|
|
1592
|
+
/** Liveness seam used to prove a staging temp's publisher is dead. */
|
|
1593
|
+
pidAlive?: (pid: number) => boolean;
|
|
1420
1594
|
}): Promise<{ removed: string[]; skipped: number }> {
|
|
1421
1595
|
const fsImpl = input.fs ?? nodeFs;
|
|
1422
1596
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1423
1597
|
await ensureDir(fsImpl, paths.dir);
|
|
1424
1598
|
const now = input.now?.() ?? Date.now();
|
|
1425
1599
|
const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
|
|
1600
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1426
1601
|
const removed: string[] = [];
|
|
1427
1602
|
let skipped = 0;
|
|
1428
1603
|
let names: string[];
|
|
@@ -1433,17 +1608,33 @@ export async function reapStaleNotificationArtifacts(input: {
|
|
|
1433
1608
|
throw error;
|
|
1434
1609
|
}
|
|
1435
1610
|
for (const name of names) {
|
|
1436
|
-
|
|
1611
|
+
const stagingTemp = NOTIFICATION_STAGING_TEMP_PATTERN.test(name);
|
|
1612
|
+
if (!isNotificationLeakArtifactName(name) && !stagingTemp) continue;
|
|
1437
1613
|
const file = path.join(paths.dir, name);
|
|
1438
1614
|
try {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1615
|
+
if (stagingTemp) {
|
|
1616
|
+
const claim = parseNotificationStagingTemp(name);
|
|
1617
|
+
// A staging-temp shape whose PID/timestamp will not parse carries no
|
|
1618
|
+
// provable claim, so it is retained.
|
|
1619
|
+
if (!claim) {
|
|
1620
|
+
skipped += 1;
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const outcome = await reapAbandonedNotificationStagingTemp({
|
|
1624
|
+
fs: fsImpl,
|
|
1625
|
+
file,
|
|
1626
|
+
claim,
|
|
1627
|
+
now,
|
|
1628
|
+
graceMs,
|
|
1629
|
+
pidAlive,
|
|
1630
|
+
});
|
|
1631
|
+
if (outcome === "removed") removed.push(file);
|
|
1632
|
+
else skipped += 1;
|
|
1443
1633
|
continue;
|
|
1444
1634
|
}
|
|
1445
|
-
await fsImpl
|
|
1446
|
-
removed.push(file);
|
|
1635
|
+
const outcome = await reapNotificationLeakArtifact({ fs: fsImpl, file, now, graceMs });
|
|
1636
|
+
if (outcome === "removed") removed.push(file);
|
|
1637
|
+
else skipped += 1;
|
|
1447
1638
|
} catch (error) {
|
|
1448
1639
|
if (isPermanentMissingPathError(error)) continue;
|
|
1449
1640
|
// Best-effort: a busy file must not fail daemon ownership.
|
|
@@ -1462,6 +1653,8 @@ export async function healTelegramDaemonNotificationState(input: {
|
|
|
1462
1653
|
fs?: TelegramDaemonFs;
|
|
1463
1654
|
now?: () => number;
|
|
1464
1655
|
graceMs?: number;
|
|
1656
|
+
/** Liveness seam used to prove a staging temp's publisher is dead. */
|
|
1657
|
+
pidAlive?: (pid: number) => boolean;
|
|
1465
1658
|
}): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
|
|
1466
1659
|
const prune = await pruneMissingNotificationRoots(input);
|
|
1467
1660
|
const reap = await reapStaleNotificationArtifacts(input);
|
|
@@ -5263,6 +5456,7 @@ export class TelegramNotificationDaemon {
|
|
|
5263
5456
|
settings: this.opts.settings,
|
|
5264
5457
|
fs: this.fsImpl,
|
|
5265
5458
|
now: this.opts.now,
|
|
5459
|
+
pidAlive: this.opts.pidAlive,
|
|
5266
5460
|
});
|
|
5267
5461
|
} catch (error) {
|
|
5268
5462
|
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
@@ -6814,17 +7008,18 @@ export class TelegramNotificationDaemon {
|
|
|
6814
7008
|
acceptedTopicId = String(tid);
|
|
6815
7009
|
this.#malformedTopicCreateEndpoints.delete(sessionId);
|
|
6816
7010
|
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
7011
|
+
const fencedCreate = this.topics.fenceAcceptedCreateForLease(
|
|
7012
|
+
sessionId,
|
|
7013
|
+
acceptedTopicId,
|
|
7014
|
+
creationLeaseEpoch,
|
|
7015
|
+
this.opts.now,
|
|
7016
|
+
name,
|
|
7017
|
+
creationBinding,
|
|
7018
|
+
);
|
|
7019
|
+
if (!fencedCreate) throw new Error("topic authority was revoked during creation");
|
|
7020
|
+
// Epoch held when the compensating delete is dispatched below; a
|
|
7021
|
+
// concurrent re-fence must not be settled by this delete's result.
|
|
7022
|
+
const fencedCreateEpoch = fencedCreate.authorityEpoch ?? 0;
|
|
6828
7023
|
try {
|
|
6829
7024
|
await this.persistTopics();
|
|
6830
7025
|
} finally {
|
|
@@ -6833,11 +7028,27 @@ export class TelegramNotificationDaemon {
|
|
|
6833
7028
|
chat_id: this.opts.chatId,
|
|
6834
7029
|
message_thread_id: tid,
|
|
6835
7030
|
});
|
|
6836
|
-
|
|
6837
|
-
|
|
7031
|
+
// Remote compensation succeeded, but the transaction is not complete
|
|
7032
|
+
// until the registry clear is durable. `acceptedTopicCompensated` is
|
|
7033
|
+
// what tells outer recovery to stop supervising the fence, so it is
|
|
7034
|
+
// set only after the phase-2 commit: a failed clear persist leaves
|
|
7035
|
+
// the fence supervised instead of stranding a cleared memory state
|
|
7036
|
+
// against a `delete_pending` disk state.
|
|
6838
7037
|
if (topicDeleteSettled(deletion)) {
|
|
6839
|
-
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
6840
|
-
|
|
7038
|
+
const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch);
|
|
7039
|
+
if (!settled) {
|
|
7040
|
+
this.#superviseCompensationFence(sessionId);
|
|
7041
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7042
|
+
} else
|
|
7043
|
+
try {
|
|
7044
|
+
await this.persistTopics();
|
|
7045
|
+
this.topics.commitSettledDelete(settled);
|
|
7046
|
+
acceptedTopicCompensated = true;
|
|
7047
|
+
} catch {
|
|
7048
|
+
this.topics.rollbackSettledDelete(settled);
|
|
7049
|
+
this.#superviseCompensationFence(sessionId);
|
|
7050
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7051
|
+
}
|
|
6841
7052
|
} else {
|
|
6842
7053
|
this.#superviseCompensationFence(sessionId);
|
|
6843
7054
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
@@ -6916,16 +7127,18 @@ export class TelegramNotificationDaemon {
|
|
|
6916
7127
|
) {
|
|
6917
7128
|
// A failed initial commit must never make compensation conditional on
|
|
6918
7129
|
// successfully publishing its fence.
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
7130
|
+
const fencedCompensation = this.topics.fenceAcceptedCreateForLease(
|
|
7131
|
+
sessionId,
|
|
7132
|
+
acceptedTopicId,
|
|
7133
|
+
creationLeaseEpoch,
|
|
7134
|
+
this.opts.now,
|
|
7135
|
+
name,
|
|
7136
|
+
creationBinding,
|
|
7137
|
+
);
|
|
7138
|
+
if (fencedCompensation) {
|
|
7139
|
+
// Epoch held when the compensating delete is dispatched below; a
|
|
7140
|
+
// concurrent re-fence must not be settled by this delete's result.
|
|
7141
|
+
const fencedCompensationEpoch = fencedCompensation.authorityEpoch ?? 0;
|
|
6929
7142
|
try {
|
|
6930
7143
|
await this.#persistTopicsWithRetry();
|
|
6931
7144
|
} catch {
|
|
@@ -6938,8 +7151,19 @@ export class TelegramNotificationDaemon {
|
|
|
6938
7151
|
message_thread_id: Number(acceptedTopicId),
|
|
6939
7152
|
});
|
|
6940
7153
|
if (topicDeleteSettled(deletion)) {
|
|
6941
|
-
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
6942
|
-
|
|
7154
|
+
const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCompensationEpoch);
|
|
7155
|
+
if (!settled) {
|
|
7156
|
+
this.#superviseCompensationFence(sessionId);
|
|
7157
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7158
|
+
} else
|
|
7159
|
+
try {
|
|
7160
|
+
await this.persistTopics();
|
|
7161
|
+
this.topics.commitSettledDelete(settled);
|
|
7162
|
+
} catch {
|
|
7163
|
+
this.topics.rollbackSettledDelete(settled);
|
|
7164
|
+
this.#superviseCompensationFence(sessionId);
|
|
7165
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7166
|
+
}
|
|
6943
7167
|
} else {
|
|
6944
7168
|
this.#superviseCompensationFence(sessionId);
|
|
6945
7169
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
@@ -6989,8 +7213,11 @@ export class TelegramNotificationDaemon {
|
|
|
6989
7213
|
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6990
7214
|
deleteFenceAlreadyPublished = false,
|
|
6991
7215
|
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
6992
|
-
const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
|
|
6993
7216
|
let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
|
|
7217
|
+
// Authority epoch held for this delete. Captured before any dispatch so a
|
|
7218
|
+
// concurrent scan/close re-fence of the same session cannot be settled by
|
|
7219
|
+
// this delete's definite result.
|
|
7220
|
+
const dispatchedAuthorityEpoch = this.topics.authorityEpoch(sessionId);
|
|
6994
7221
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
6995
7222
|
await this.persistTopics();
|
|
6996
7223
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
@@ -7013,7 +7240,13 @@ export class TelegramNotificationDaemon {
|
|
|
7013
7240
|
await this.flushPool();
|
|
7014
7241
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
7015
7242
|
if (record.topicOrigin === "user_created") {
|
|
7016
|
-
|
|
7243
|
+
// Phase 1: drop the record but keep the topic id quarantined. Routes are
|
|
7244
|
+
// only republished by `commitSettledDelete` once the clear is durable.
|
|
7245
|
+
const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
|
|
7246
|
+
if (!settled) {
|
|
7247
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7248
|
+
return "post_dispatch_pending";
|
|
7249
|
+
}
|
|
7017
7250
|
for (const k of [...this.liveMessages.keys()])
|
|
7018
7251
|
if (k.startsWith(`${sessionId}:`)) {
|
|
7019
7252
|
this.liveMessages.delete(k);
|
|
@@ -7025,9 +7258,10 @@ export class TelegramNotificationDaemon {
|
|
|
7025
7258
|
this.pendingThreadedFrames.delete(sessionId);
|
|
7026
7259
|
try {
|
|
7027
7260
|
await this.persistTopics();
|
|
7261
|
+
this.topics.commitSettledDelete(settled);
|
|
7028
7262
|
return "settled";
|
|
7029
7263
|
} catch {
|
|
7030
|
-
this.topics.
|
|
7264
|
+
this.topics.rollbackSettledDelete(settled);
|
|
7031
7265
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7032
7266
|
return "post_dispatch_pending";
|
|
7033
7267
|
}
|
|
@@ -7037,7 +7271,11 @@ export class TelegramNotificationDaemon {
|
|
|
7037
7271
|
message_thread_id: Number(record.topicId),
|
|
7038
7272
|
})) as { ok?: boolean };
|
|
7039
7273
|
if (!topicDeleteSettled(res)) return "post_dispatch_pending";
|
|
7040
|
-
this.topics.settleDelete(sessionId, record.topicId);
|
|
7274
|
+
const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
|
|
7275
|
+
if (!settled) {
|
|
7276
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7277
|
+
return "post_dispatch_pending";
|
|
7278
|
+
}
|
|
7041
7279
|
for (const k of [...this.liveMessages.keys()])
|
|
7042
7280
|
if (k.startsWith(`${sessionId}:`)) {
|
|
7043
7281
|
this.liveMessages.delete(k);
|
|
@@ -7049,9 +7287,10 @@ export class TelegramNotificationDaemon {
|
|
|
7049
7287
|
this.pendingThreadedFrames.delete(sessionId);
|
|
7050
7288
|
try {
|
|
7051
7289
|
await this.persistTopics();
|
|
7290
|
+
this.topics.commitSettledDelete(settled);
|
|
7052
7291
|
return "settled";
|
|
7053
7292
|
} catch {
|
|
7054
|
-
this.topics.
|
|
7293
|
+
this.topics.rollbackSettledDelete(settled);
|
|
7055
7294
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7056
7295
|
return "post_dispatch_pending";
|
|
7057
7296
|
}
|