@llblab/pi-kit 0.35.1 → 0.35.3
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/BACKLOG.md +2 -0
- package/CHANGELOG.md +11 -0
- package/README.md +3 -3
- package/extensions/pi-actors/index.js +4 -0
- package/extensions/pi-claude-usage/index.ts +4 -0
- package/extensions/pi-clean-room/index.ts +4 -0
- package/extensions/pi-codex-usage/index.ts +4 -0
- package/extensions/pi-grow-loop/index.js +4 -0
- package/extensions/pi-state-flow/index.js +4 -0
- package/extensions/pi-telegram/index.js +4 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -1
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -11
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -1
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
- package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +8 -5
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +12 -5
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +10 -9
|
@@ -8,6 +8,82 @@ import { basename, dirname, join } from "node:path";
|
|
|
8
8
|
import { withTelegramFileTransaction } from "./locks.js";
|
|
9
9
|
import { resolveAgentDir, resolveTelegramPreviousSharedRuntimeLogPath, resolveTelegramProfileTempFilePath, resolveTelegramRuntimeLogPath, } from "./paths.js";
|
|
10
10
|
import * as Status from "./status.js";
|
|
11
|
+
/** Stable identity of one failure kind: errno when present, otherwise the failure text. */
|
|
12
|
+
export function getTelegramFailureSignature(error) {
|
|
13
|
+
const code = error?.code;
|
|
14
|
+
if (typeof code === "string" && code)
|
|
15
|
+
return code;
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
|
|
20
|
+
* and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
|
|
21
|
+
*/
|
|
22
|
+
export function createTelegramFailureRunLog(input) {
|
|
23
|
+
const getNowMs = input.getNowMs ?? Date.now;
|
|
24
|
+
let run;
|
|
25
|
+
const emit = (errorOrMessage, details) => {
|
|
26
|
+
try {
|
|
27
|
+
input.record?.(input.category, errorOrMessage, {
|
|
28
|
+
...(input.context?.() ?? {}),
|
|
29
|
+
...details,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A diagnostic sink never converts a recorded failure into another failure.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
record(error, details) {
|
|
38
|
+
const signature = getTelegramFailureSignature(error);
|
|
39
|
+
const admitted = { ...(details ?? {}) };
|
|
40
|
+
if (run?.signature === signature) {
|
|
41
|
+
run.suppressed += 1;
|
|
42
|
+
Object.assign(run.details, admitted);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const previous = run;
|
|
46
|
+
run = { signature, suppressed: 0, startedAtMs: getNowMs(), details: admitted };
|
|
47
|
+
emit(error, {
|
|
48
|
+
...admitted,
|
|
49
|
+
phase: input.failurePhase,
|
|
50
|
+
failureSignature: signature,
|
|
51
|
+
...(previous
|
|
52
|
+
? {
|
|
53
|
+
previousFailureSignature: previous.signature,
|
|
54
|
+
previousFailedAttempts: previous.suppressed + 1,
|
|
55
|
+
previousFailedForMs: getNowMs() - previous.startedAtMs,
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
});
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
settled() {
|
|
62
|
+
const closed = run;
|
|
63
|
+
if (!closed)
|
|
64
|
+
return undefined;
|
|
65
|
+
run = undefined;
|
|
66
|
+
const summary = {
|
|
67
|
+
signature: closed.signature,
|
|
68
|
+
failedAttempts: closed.suppressed + 1,
|
|
69
|
+
suppressedFailures: closed.suppressed,
|
|
70
|
+
failedForMs: getNowMs() - closed.startedAtMs,
|
|
71
|
+
};
|
|
72
|
+
emit(`Telegram ${input.label} recovered after ${summary.failedAttempts} failed attempts.`, {
|
|
73
|
+
...closed.details,
|
|
74
|
+
phase: input.outcomePhase,
|
|
75
|
+
failureSignature: summary.signature,
|
|
76
|
+
failedAttempts: summary.failedAttempts,
|
|
77
|
+
suppressedFailures: summary.suppressedFailures,
|
|
78
|
+
failedForMs: summary.failedForMs,
|
|
79
|
+
});
|
|
80
|
+
return summary;
|
|
81
|
+
},
|
|
82
|
+
reset() {
|
|
83
|
+
run = undefined;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
11
87
|
const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
12
88
|
export function getTelegramRuntimeLogPath(agentDir = resolveAgentDir(), profileName) {
|
|
13
89
|
return resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profileName);
|
|
@@ -333,6 +409,10 @@ export function createTelegramRuntimeDiagnosticsRuntime(options = {}) {
|
|
|
333
409
|
};
|
|
334
410
|
return {
|
|
335
411
|
events,
|
|
412
|
+
createFailureLog: (input) => createTelegramFailureRunLog({
|
|
413
|
+
...input,
|
|
414
|
+
record: (category, errorOrMessage, details) => recordRuntimeEvent(category, errorOrMessage, details),
|
|
415
|
+
}),
|
|
336
416
|
recordRuntimeEvent,
|
|
337
417
|
bindStorage(ports) {
|
|
338
418
|
getBotToken = ports.getBotToken;
|
|
@@ -43,7 +43,8 @@ export declare function resolveLegacyTelegramOwnersPath(agentDir?: string): stri
|
|
|
43
43
|
export declare function resolveTelegramOwnersPath(): string;
|
|
44
44
|
export declare function getTelegramProfilePathSuffix(profileName?: string): string;
|
|
45
45
|
export declare function resolveTelegramProfileTempFilePath(baseName: string, extension: string, agentDir?: string, profileName?: string): string;
|
|
46
|
-
|
|
46
|
+
/** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
|
|
47
|
+
export declare function getTelegramDiagnosticsDisplayPaths(_profileName?: string, agentDir?: string): {
|
|
47
48
|
state: string;
|
|
48
49
|
logs: string;
|
|
49
50
|
};
|
|
@@ -111,10 +111,25 @@ export function getTelegramProfilePathSuffix(profileName) {
|
|
|
111
111
|
export function resolveTelegramProfileTempFilePath(baseName, extension, agentDir = resolveAgentDir(), profileName) {
|
|
112
112
|
return join(resolveTelegramTempDir(agentDir), `${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`);
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
/** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
|
|
115
|
+
export function getTelegramDiagnosticsDisplayPaths(_profileName, agentDir = resolveAgentDir()) {
|
|
116
|
+
// Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
|
|
117
|
+
const homes = [homedir()];
|
|
118
|
+
try {
|
|
119
|
+
homes.push(realpathSync(homes[0]));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* An unreadable home only loses the ~ abbreviation. */
|
|
123
|
+
}
|
|
124
|
+
const display = (path) => {
|
|
125
|
+
const home = homes.find((candidate) => path === candidate || path.startsWith(`${candidate}${sep}`));
|
|
126
|
+
return home
|
|
127
|
+
? `~${path.slice(home.length).split(sep).join("/")}`
|
|
128
|
+
: path;
|
|
129
|
+
};
|
|
115
130
|
return {
|
|
116
|
-
state:
|
|
117
|
-
logs:
|
|
131
|
+
state: display(resolveTelegramStatePath(agentDir)),
|
|
132
|
+
logs: display(resolveTelegramRuntimeLogPath(agentDir)),
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
/** Durable Workspace admission ledger (<agentDir>/tmp/pi-telegram/workspace-admission[.<profile>].json). */
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
|
|
5
5
|
*/
|
|
6
6
|
type MaybePromise<T> = T | Promise<T>;
|
|
7
|
+
/**
|
|
8
|
+
* Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
|
|
9
|
+
* again, and the closing success records one outcome. The composition root owns the collapsed implementation.
|
|
10
|
+
*/
|
|
11
|
+
export interface TelegramRepeatedFailureLogPort {
|
|
12
|
+
record: (error: unknown, details?: Record<string, unknown>) => void;
|
|
13
|
+
settled: () => void;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
7
16
|
export interface TelegramPollingConfig {
|
|
8
17
|
botToken?: string;
|
|
9
18
|
}
|
|
@@ -174,6 +183,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
|
|
|
174
183
|
getNowMs?: () => number;
|
|
175
184
|
}
|
|
176
185
|
export interface TelegramThreadCapabilityRuntimeDeps<TContext> extends TelegramThreadCapabilityReaderDeps {
|
|
186
|
+
/** Collapses one sustained probe failure run; the caller supplies its owner. */
|
|
187
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
177
188
|
lifecycle?: TelegramThreadCapabilityLifecycle;
|
|
178
189
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
179
190
|
ownsLock: (ctx: TContext) => boolean;
|
|
@@ -251,6 +262,8 @@ export interface TelegramThreadAwarePollingDeps<TContext, TOwner> extends Telegr
|
|
|
251
262
|
setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
|
|
252
263
|
}
|
|
253
264
|
export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner> extends TelegramThreadCapabilityReaderDeps, TelegramThreadAwarePollingControls<TContext, TOwner> {
|
|
265
|
+
/** Collapses one sustained probe failure run; the composition root supplies its owner. */
|
|
266
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
254
267
|
state: TelegramThreadCapabilityStateRuntime;
|
|
255
268
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
256
269
|
ownsLock: (ctx: TContext) => boolean;
|
|
@@ -471,6 +471,7 @@ export function createTelegramThreadCapabilityOrchestration(deps) {
|
|
|
471
471
|
isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
|
|
472
472
|
updateStatus: deps.updateStatus,
|
|
473
473
|
recordEvent: deps.recordEvent,
|
|
474
|
+
failureLog: deps.failureLog,
|
|
474
475
|
};
|
|
475
476
|
return {
|
|
476
477
|
monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
|
|
@@ -813,8 +814,37 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
813
814
|
let generation = 0;
|
|
814
815
|
let transitionPromise;
|
|
815
816
|
let consecutiveDisabledProbes = 0;
|
|
817
|
+
// An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
|
|
818
|
+
// Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
|
|
819
|
+
const capabilityFailureLog = {
|
|
820
|
+
record(error, details) {
|
|
821
|
+
try {
|
|
822
|
+
deps.failureLog.record(error, details);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
settled() {
|
|
829
|
+
try {
|
|
830
|
+
deps.failureLog.settled();
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// Monitor diagnostics cannot fail a completed probe.
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
reset() {
|
|
837
|
+
try {
|
|
838
|
+
deps.failureLog.reset();
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// Monitor diagnostics cannot fail a monitor stop.
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
};
|
|
816
845
|
const stop = () => {
|
|
817
846
|
generation += 1;
|
|
847
|
+
capabilityFailureLog.reset();
|
|
818
848
|
if (interval)
|
|
819
849
|
clearInterval(interval);
|
|
820
850
|
interval = undefined;
|
|
@@ -832,6 +862,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
832
862
|
.then(async (threadModeEnabled) => {
|
|
833
863
|
if (!isCurrent())
|
|
834
864
|
return;
|
|
865
|
+
capabilityFailureLog.settled();
|
|
835
866
|
if (threadModeEnabled === undefined) {
|
|
836
867
|
if (deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
|
|
837
868
|
!deps.getPollingStartedWithTelegramBus() &&
|
|
@@ -879,12 +910,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
879
910
|
.catch((error) => {
|
|
880
911
|
if (!isCurrent())
|
|
881
912
|
return;
|
|
882
|
-
|
|
883
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
884
|
-
}
|
|
885
|
-
catch {
|
|
886
|
-
// Monitor diagnostics cannot create an unhandled interval rejection.
|
|
887
|
-
}
|
|
913
|
+
capabilityFailureLog.record(error);
|
|
888
914
|
})
|
|
889
915
|
.finally(() => {
|
|
890
916
|
if (transitionPromise === tracked)
|
|
@@ -911,12 +937,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
911
937
|
catch {
|
|
912
938
|
/* Timer shutdown must not escape the callback. */
|
|
913
939
|
}
|
|
914
|
-
|
|
915
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
919
|
-
}
|
|
940
|
+
capabilityFailureLog.record(error);
|
|
920
941
|
}
|
|
921
942
|
}, intervalMs);
|
|
922
943
|
interval.unref?.();
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
|
|
5
5
|
*/
|
|
6
6
|
import * as Machine from "./machine.ts";
|
|
7
|
+
/**
|
|
8
|
+
* Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
|
|
9
|
+
* again, and the closing success records one outcome. The composition root owns the collapsed implementation.
|
|
10
|
+
*/
|
|
11
|
+
export interface TelegramRepeatedFailureLogPort {
|
|
12
|
+
record: (error: unknown, details?: Record<string, unknown>) => void;
|
|
13
|
+
settled: () => void;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
7
16
|
export interface TelegramRuntimeQueueCounters {
|
|
8
17
|
nextQueuedTelegramItemOrder: number;
|
|
9
18
|
nextQueuedTelegramControlOrder: number;
|
|
@@ -103,6 +112,8 @@ export interface TelegramRuntimeEventRecorderPort {
|
|
|
103
112
|
recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
|
|
104
113
|
}
|
|
105
114
|
export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntimeEventRecorderPort {
|
|
115
|
+
/** Collapses one sustained presence failure run; the caller supplies its owner. */
|
|
116
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
106
117
|
typing: TelegramRuntimeTypingPort;
|
|
107
118
|
getDefaultChatId: () => number | undefined;
|
|
108
119
|
sendTypingAction: (chatId: number, options?: {
|
|
@@ -162,6 +162,34 @@ function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
|
|
|
162
162
|
}
|
|
163
163
|
export function createTelegramTypingLoopStarter(deps) {
|
|
164
164
|
let currentOwner;
|
|
165
|
+
// An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
|
|
166
|
+
// Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
|
|
167
|
+
const typingFailureLog = {
|
|
168
|
+
record(error, details) {
|
|
169
|
+
try {
|
|
170
|
+
deps.failureLog.record(error, details);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Typing diagnostics cannot escape the in-flight action owner.
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
settled() {
|
|
177
|
+
try {
|
|
178
|
+
deps.failureLog.settled();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A successful presence outranks its own diagnostic.
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
reset() {
|
|
185
|
+
try {
|
|
186
|
+
deps.failureLog.reset();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Typing diagnostics cannot fail a loop stop.
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
165
193
|
return (ctx, chatId, options) => {
|
|
166
194
|
const sessionGeneration = deps.getSessionGeneration?.();
|
|
167
195
|
const transportAuthority = deps.getTransportAuthority?.();
|
|
@@ -214,6 +242,7 @@ export function createTelegramTypingLoopStarter(deps) {
|
|
|
214
242
|
throw new Error("Typing context authority changed.");
|
|
215
243
|
},
|
|
216
244
|
});
|
|
245
|
+
typingFailureLog.settled();
|
|
217
246
|
}
|
|
218
247
|
catch (error) {
|
|
219
248
|
if (!active)
|
|
@@ -222,19 +251,13 @@ export function createTelegramTypingLoopStarter(deps) {
|
|
|
222
251
|
deps.typing.stop();
|
|
223
252
|
return;
|
|
224
253
|
}
|
|
225
|
-
|
|
226
|
-
deps.recordRuntimeEvent?.("typing", error, {
|
|
227
|
-
chatId: targetChatId,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
// Typing diagnostics cannot escape the in-flight action owner.
|
|
232
|
-
}
|
|
254
|
+
typingFailureLog.record(error, { chatId: targetChatId });
|
|
233
255
|
}
|
|
234
256
|
},
|
|
235
257
|
shouldContinue: canContinue,
|
|
236
258
|
onStopped: () => {
|
|
237
259
|
active = false;
|
|
260
|
+
typingFailureLog.reset();
|
|
238
261
|
if (currentOwner === owner)
|
|
239
262
|
currentOwner = undefined;
|
|
240
263
|
},
|
|
@@ -299,6 +299,8 @@ export interface TelegramApiCallOptions {
|
|
|
299
299
|
sleep?: (ms: number) => Promise<void>;
|
|
300
300
|
/** Observability hook fired before a 429 retry wait, never for 5xx waits. */
|
|
301
301
|
onRetryWait?: (wait: TelegramApiRetryWait) => void;
|
|
302
|
+
/** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
|
|
303
|
+
isExpectedError?: (error: unknown) => boolean;
|
|
302
304
|
}
|
|
303
305
|
export declare class TelegramApiAuthorityError extends Error {
|
|
304
306
|
readonly requestIssued: boolean;
|
|
@@ -1134,7 +1134,8 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1134
1134
|
"Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
|
|
1135
1135
|
return true;
|
|
1136
1136
|
}
|
|
1137
|
-
|
|
1137
|
+
if (!options?.isExpectedError?.(error))
|
|
1138
|
+
deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
|
|
1138
1139
|
throw error;
|
|
1139
1140
|
}
|
|
1140
1141
|
};
|
|
@@ -339,7 +339,11 @@ export async function applyThreadReconciliationPlan(plan, ports) {
|
|
|
339
339
|
}
|
|
340
340
|
let deleteConfirmed = false;
|
|
341
341
|
let superseded = false;
|
|
342
|
-
|
|
342
|
+
// Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
|
|
343
|
+
const skipClose = ports.skipCloseBeforeDelete ||
|
|
344
|
+
(action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
345
|
+
action.target.chatId > 0);
|
|
346
|
+
for (const method of skipClose
|
|
343
347
|
? ["deleteForumTopic"]
|
|
344
348
|
: ["closeForumTopic", "deleteForumTopic"]) {
|
|
345
349
|
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
@@ -157,6 +157,10 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
|
|
|
157
157
|
acceptedWork: TelegramWorkspaceProtectionState;
|
|
158
158
|
deliveryAuthority: TelegramWorkspaceProtectionState;
|
|
159
159
|
}
|
|
160
|
+
/** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
|
|
161
|
+
export type TelegramWorkspaceExternalProtectionCapture = ((binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence) & {
|
|
162
|
+
withSnapshot?: <T>(capture: () => T) => T;
|
|
163
|
+
};
|
|
160
164
|
export interface TelegramWorkspaceSlotOccupancySnapshot {
|
|
161
165
|
bindings: TelegramWorkspaceSlotOccupancy[];
|
|
162
166
|
reservedSlots: string[];
|
|
@@ -549,7 +553,7 @@ export interface TelegramTopicTargetStore {
|
|
|
549
553
|
bindingUpdatedAtMs: number;
|
|
550
554
|
}, isCurrent: () => boolean) => Promise<boolean>;
|
|
551
555
|
/** Caller must separately prove no external live owner, accepted work, or delivery authority. */
|
|
552
|
-
captureWorkspaceSlotOccupancy: (getExternalProtection:
|
|
556
|
+
captureWorkspaceSlotOccupancy: (getExternalProtection: TelegramWorkspaceExternalProtectionCapture, options?: {
|
|
553
557
|
expectedRetirement?: TelegramWorkspaceRetirementIntent;
|
|
554
558
|
}) => TelegramWorkspaceSlotOccupancySnapshot;
|
|
555
559
|
hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
|
|
@@ -4681,7 +4681,8 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
4681
4681
|
},
|
|
4682
4682
|
captureWorkspaceSlotOccupancy(getExternalProtection, options) {
|
|
4683
4683
|
const nowMs = getNowMs();
|
|
4684
|
-
const
|
|
4684
|
+
const inSnapshot = getExternalProtection.withSnapshot ?? ((capture) => capture());
|
|
4685
|
+
const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
|
|
4685
4686
|
const slot = binding.slot?.toLowerCase() ?? "";
|
|
4686
4687
|
const locallyProtected = isWorkspaceBindingLocallyProtected(binding, nowMs, {
|
|
4687
4688
|
requireBindingSlot: true,
|
|
@@ -4711,7 +4712,7 @@ export function createTelegramTopicTargetStore(options) {
|
|
|
4711
4712
|
: {}),
|
|
4712
4713
|
protection,
|
|
4713
4714
|
};
|
|
4714
|
-
});
|
|
4715
|
+
}));
|
|
4715
4716
|
const localReservedSlots = [
|
|
4716
4717
|
...Array.from(workspaceClaims.values()).map((claim) => claim.identity.slot),
|
|
4717
4718
|
...Array.from(records.values())
|
|
@@ -57,6 +57,8 @@ export declare function captureTelegramWorkspaceJournalProtectionSources(input:
|
|
|
57
57
|
complete: boolean;
|
|
58
58
|
createResolver: (path: string) => () => TelegramWorkspaceJournalReader | undefined;
|
|
59
59
|
};
|
|
60
|
+
/** Snapshot-scoped reuse of binding-independent (shared/discovered) source reads. */
|
|
61
|
+
sharedSourceReads?: Map<string, TelegramWorkspaceJournalProtectionSource>;
|
|
60
62
|
}): TelegramWorkspaceJournalProtectionCapture;
|
|
61
63
|
export type TelegramWorkspaceJournalProtectionSource = {
|
|
62
64
|
kind: "available";
|
|
@@ -158,9 +160,11 @@ interface TelegramWorkspaceProtectionObserverDeps {
|
|
|
158
160
|
getDeliveryAuthorityProtection?: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceProtectionState;
|
|
159
161
|
}
|
|
160
162
|
export interface TelegramWorkspaceProtectionObserver {
|
|
161
|
-
capture: (binding: TelegramWorkspaceThreadBinding, options?: {
|
|
163
|
+
capture: ((binding: TelegramWorkspaceThreadBinding, options?: {
|
|
162
164
|
requireBindingProvenance?: boolean;
|
|
163
|
-
}) => TelegramWorkspaceExternalProtectionEvidence
|
|
165
|
+
}) => TelegramWorkspaceExternalProtectionEvidence) & {
|
|
166
|
+
withSnapshot?: <T>(capture: () => T) => T;
|
|
167
|
+
};
|
|
164
168
|
captureJournalSources: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceJournalProtectionCapture;
|
|
165
169
|
getJournalWriterProtection: (journalBindingKey: string) => TelegramWorkspaceProtectionState;
|
|
166
170
|
}
|
|
@@ -46,27 +46,41 @@ export function captureTelegramWorkspaceJournalProtectionSources(input) {
|
|
|
46
46
|
let complete = input.binding.journalBindingsComplete === true ||
|
|
47
47
|
input.discovery?.complete === true;
|
|
48
48
|
const capture = (scope, resolve) => {
|
|
49
|
+
const sharedKey = scope.kind === "shared"
|
|
50
|
+
? "shared"
|
|
51
|
+
: scope.kind === "discovered"
|
|
52
|
+
? `discovered:${scope.path}`
|
|
53
|
+
: undefined;
|
|
54
|
+
let source = sharedKey === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: input.sharedSourceReads?.get(sharedKey);
|
|
57
|
+
if (!source) {
|
|
58
|
+
source = readSource(scope, resolve);
|
|
59
|
+
if (sharedKey !== undefined)
|
|
60
|
+
input.sharedSourceReads?.set(sharedKey, source);
|
|
61
|
+
}
|
|
62
|
+
if (source.kind === "unknown")
|
|
63
|
+
complete = false;
|
|
64
|
+
sources.push(source);
|
|
65
|
+
};
|
|
66
|
+
const readSource = (scope, resolve) => {
|
|
49
67
|
try {
|
|
50
68
|
const binding = resolve();
|
|
51
|
-
if (!binding)
|
|
52
|
-
|
|
53
|
-
sources.push({ kind: "unknown", scope });
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
69
|
+
if (!binding)
|
|
70
|
+
return { kind: "unknown", scope };
|
|
56
71
|
const read = binding.readForProtection ?? binding.journal.read;
|
|
57
72
|
const snapshot = input.withJournalReference
|
|
58
73
|
? input.withJournalReference(binding, read)
|
|
59
74
|
: read();
|
|
60
|
-
|
|
75
|
+
return {
|
|
61
76
|
kind: "available",
|
|
62
77
|
scope,
|
|
63
78
|
entries: snapshot.entries,
|
|
64
79
|
...(snapshot.exists === undefined ? {} : { exists: snapshot.exists }),
|
|
65
|
-
}
|
|
80
|
+
};
|
|
66
81
|
}
|
|
67
82
|
catch {
|
|
68
|
-
|
|
69
|
-
sources.push({ kind: "unknown", scope });
|
|
83
|
+
return { kind: "unknown", scope };
|
|
70
84
|
}
|
|
71
85
|
};
|
|
72
86
|
capture({ kind: "shared" }, input.resolveLeader);
|
|
@@ -292,8 +306,38 @@ export function captureTelegramWorkspaceExternalProtection(input) {
|
|
|
292
306
|
}
|
|
293
307
|
/** Shared read-only observer: protection and metadata pruning use the same scoped evidence path. */
|
|
294
308
|
export function createTelegramWorkspaceProtectionObserver(deps) {
|
|
309
|
+
// One occupancy snapshot probes every binding synchronously; namespace and shared/discovered
|
|
310
|
+
// journal reads are binding-independent, so read them once per snapshot instead of per binding.
|
|
311
|
+
// Never widen this beyond one synchronous capture: post-fence rechecks need fresh evidence.
|
|
312
|
+
let snapshot;
|
|
313
|
+
const inspectNamespace = () => {
|
|
314
|
+
if (!snapshot)
|
|
315
|
+
return deps.inspectJournalNamespace?.();
|
|
316
|
+
if (!snapshot.namespace) {
|
|
317
|
+
try {
|
|
318
|
+
snapshot.namespace = { value: deps.inspectJournalNamespace?.() };
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
snapshot.namespace = { error };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if ("error" in snapshot.namespace)
|
|
325
|
+
throw snapshot.namespace.error;
|
|
326
|
+
return snapshot.namespace.value;
|
|
327
|
+
};
|
|
328
|
+
const withSnapshot = (run) => {
|
|
329
|
+
if (snapshot)
|
|
330
|
+
return run();
|
|
331
|
+
snapshot = { reads: new Map() };
|
|
332
|
+
try {
|
|
333
|
+
return run();
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
snapshot = undefined;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
295
339
|
const captureJournalSources = (candidate) => {
|
|
296
|
-
const namespace =
|
|
340
|
+
const namespace = inspectNamespace();
|
|
297
341
|
if (deps.inspectJournalNamespace &&
|
|
298
342
|
(!namespace ||
|
|
299
343
|
namespace.sources.filter((source) => source.role === "polling")
|
|
@@ -332,9 +376,10 @@ export function createTelegramWorkspaceProtectionObserver(deps) {
|
|
|
332
376
|
},
|
|
333
377
|
}
|
|
334
378
|
: {}),
|
|
379
|
+
...(snapshot ? { sharedSourceReads: snapshot.reads } : {}),
|
|
335
380
|
});
|
|
336
381
|
};
|
|
337
|
-
const
|
|
382
|
+
const captureOne = function (binding, options) {
|
|
338
383
|
return captureTelegramWorkspaceExternalProtection({
|
|
339
384
|
binding,
|
|
340
385
|
requireBindingProvenance: options?.requireBindingProvenance,
|
|
@@ -384,7 +429,7 @@ export function createTelegramWorkspaceProtectionObserver(deps) {
|
|
|
384
429
|
});
|
|
385
430
|
};
|
|
386
431
|
return {
|
|
387
|
-
capture,
|
|
432
|
+
capture: Object.assign(captureOne, { withSnapshot }),
|
|
388
433
|
captureJournalSources,
|
|
389
434
|
getJournalWriterProtection: deps.getJournalWriterProtection ?? (() => "unknown"),
|
|
390
435
|
};
|
|
@@ -128,7 +128,7 @@ This operator-approved disposable-session policy is not Thread deletion, receipt
|
|
|
128
128
|
|
|
129
129
|
### Follower Registration And Session Readiness
|
|
130
130
|
|
|
131
|
-
Manual Connect may carry a Pi command-context object distinct from the receiver's canonical session context. Binding preparation uses that canonical object only after both contexts prove the same current, leader-acknowledged session ID and session generation; a distinct object requires explicit active-context and generation ports. Readiness captures the canonical object and rechecks its identity after preparation and on every read. Missing, inactive, foreign or replaced canonical contexts grant no inbound authority. Same-session command-context refresh preserves the prepared worker and held input; generation, session or registration replacement still invalidates readiness. Worker context equality and exact journal/custody checks remain unchanged.
|
|
131
|
+
Manual Connect may carry a Pi command-context object distinct from the receiver's canonical session context. Binding preparation uses that canonical object only after both contexts prove the same current, leader-acknowledged session ID and session generation; a distinct object requires explicit active-context and generation ports. Readiness captures the canonical object and rechecks its identity after preparation and on every read. Missing, inactive, foreign or replaced canonical contexts grant no inbound authority. Same-session command-context refresh preserves the prepared worker and held input; generation, session or registration replacement still invalidates readiness. Worker context equality and exact journal/custody checks remain unchanged. That distinction is a readiness guard only: promotion and every other post-online behavior use the canonical live session context, so a fresh Pi context object for the same session never blocks or changes them, and a replaced session generation or ID revokes them.
|
|
132
132
|
|
|
133
133
|
Same-target registration distinguishes the provisional Workspace claim from the binding actually returned by commit. A reused target may retain its existing slot while changing binding key; before persistence and registration publication, the leader aligns that target's owner record and registration result with the committed slot. The final exact binding/slot/target check and leader epoch/profile/operator fences remain mandatory; drift refuses publication rather than weakening the guard. Re-keying preserves retained historical journal keys and exact session/recipient source tuples, and cannot turn unknown retained coverage into a completeness proof. Independent bindings and their slots/history remain untouched.
|
|
134
134
|
|
|
@@ -271,6 +271,8 @@ Leader election is liveness-proof-gated and lock-backed. Since 0.54 the leader w
|
|
|
271
271
|
|
|
272
272
|
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries, successor follower processes with the same exact Workspace claim, and leader reloads probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
273
273
|
|
|
274
|
+
Participation is a property of live registered state alone: eligibility, deferral, and promotion depend on the acknowledged slot, the authenticated registration generation, and the current session, never on how that instance came online. A fresh Pi context object for the same session carries the same authority, and only a replaced session generation or ID revokes it.
|
|
275
|
+
|
|
274
276
|
## Leader/Follower Communication
|
|
275
277
|
|
|
276
278
|
### Protocol identity and compatibility
|