@llblab/pi-telegram 0.22.0 → 0.22.1
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/AGENTS.md +1 -0
- package/BACKLOG.md +29 -1
- package/CHANGELOG.md +5 -0
- package/docs/architecture.md +1 -1
- package/docs/locks.md +6 -2
- package/lib/locks.ts +318 -34
- package/lib/logs.ts +7 -3
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -76,6 +76,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
76
76
|
- The bridge is session-local, paired to one allowed Telegram user, and owns a local queue aligned with Pi lifecycle hooks
|
|
77
77
|
- The Telegram lock owns external Telegram transport, not local Pi execution. Local extension state and queue runtime are per Pi instance: losing exact `/telegram-connect` ownership must not clear accepted queue state or stop local Pi dispatch, but preview, final delivery, attachments, menus, and other Bot API mutations fail closed once another runtime owns transport. A replacement owner must never receive stale direct mutations merely to preserve old-runtime Telegram output. Bus leader lock entries carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned Thread Reconciler cleanup/provisioning actions stamp that epoch and skip destructive side effects if current leadership differs.
|
|
78
78
|
- The Telegram lock is always the transport ownership lock. Every shared `locks.json` mutation must serialize the complete cross-process read/check/write transaction, preserve unrelated registry keys, and fail closed on transaction contention; atomic payload replacement alone does not make ownership linearizable. Refresh, release, takeover, and irreversible leader work must additionally fence against the exact acquired owner and leader epoch. In classic/private-chat mode, transport ownership is the whole bridge ownership and `/telegram-connect` from another live Pi should offer the classic takeover confirmation. In Threaded Mode, transport ownership is the bus leader role and `/telegram-connect` from another live Pi should register as follower instead of offering takeover while the leader is live. If follower registration is not applicable because Threaded Mode is disabled or unavailable, fall back to the classic takeover path.
|
|
79
|
+
- Shared filesystem transactions publish a fully initialized private guard directory containing one exact generation-specific `owner.<generation>.json` through same-parent atomic rename. The owner filename and payload generation must match so stale observation cannot rename replacement metadata. Release verifies the owner and atomically renames the stable guard away before cleanup. Directory recovery first claims owner metadata inside the still-occupied guard so a crashed recoverer can itself be replaced without exposing the stable path; transient guard moves and rollback both retry, while inactive process-global claim generations remain recoverable after failed restoration. If secondary recovery cleanup fails after publishing a replacement main guard, release that exact replacement before propagating the failure. Legacy regular-file recovery remains separately serialized through recoverable directory guards. New guards must not depend on hard links, direct file rename-overwrite, incomplete create-then-write state, or age-only recovery. Legacy regular-file guards may be read only for bounded upgrade recovery; malformed or unsupported guards fail closed.
|
|
79
80
|
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Shared bridge settings remain global, but any lock, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Preserve default-profile legacy paths/keys for compatibility, and give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
|
|
80
81
|
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
|
81
82
|
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for explicit disconnect or confirmed reconciliation cases. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` is a manual thread teardown: a leader deletes its own bound thread before releasing transport ownership, while a follower asks the leader to delete its assigned thread through scoped bus API before unregistering. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
package/BACKLOG.md
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
# Project Backlog
|
|
2
2
|
|
|
3
|
-
_This backlog tracks only open release-relevant work: live runtime verification, evidence-gated Telegram client follow-ups, and upstream Pi API blockers. Completed outcomes and validation evidence belong in `CHANGELOG.md`, not in this queue._
|
|
3
|
+
_This backlog tracks only open release-relevant work: hotfixes, live runtime verification, evidence-gated Telegram client follow-ups, and upstream Pi API blockers. Completed outcomes and validation evidence belong in `CHANGELOG.md`, not in this queue._
|
|
4
|
+
|
|
5
|
+
## P0 — Termux-Compatible Filesystem Transactions (`0.22.1`)
|
|
6
|
+
|
|
7
|
+
Context: issue #131 proves that Android/Termux rejects the hard-link publication used by the `0.22.0` transaction guard, causing Pi to exit during JSONL initialization. A direct file-rename fallback is unsafe because rename replaces an existing destination and can admit multiple transaction owners. The hotfix keeps per-resource serialization but publishes a fully initialized, non-empty guard directory atomically.
|
|
8
|
+
|
|
9
|
+
Open work:
|
|
10
|
+
|
|
11
|
+
- [x] Replace staged-file/hard-link publication with a staged guard directory containing one private generation-specific owner file, then atomically rename that non-empty directory to the stable per-resource transaction path.
|
|
12
|
+
- [x] Keep collision-resistant owner generation plus PID and acquisition time, exact-owner verification, serialized stale recovery, and fail-closed malformed-guard behavior.
|
|
13
|
+
- [x] Release guards by exact-owner atomic rename away from the stable path before recursive cleanup; support stale legacy file guards left by `0.22.0` during upgrade.
|
|
14
|
+
- [x] Ensure JSONL append/rotation transaction failures remain diagnostics-only and cannot produce an unhandled rejection that terminates Pi.
|
|
15
|
+
- [x] Add deterministic regressions for directory publication, exact release, legacy and directory stale recovery, malformed guards, simultaneous acquisition, concurrent recovery, and swallowed diagnostics failures.
|
|
16
|
+
- [x] Update lock/diagnostics documentation and durable transaction contracts for the staged-directory protocol and fail-soft JSONL behavior.
|
|
17
|
+
- [x] Run implementation tests, typecheck, strict Domain DAG, ABCd, audit, package dry-run, invariants, and `git diff --check`.
|
|
18
|
+
- [x] Run an independent four-lens concurrency/filesystem review of directory publication, contention classification, stale recovery, exact release, legacy migration, and diagnostics containment; preserve its `NOT READY` evidence for the reproduced abandoned-recovery-guard deadlock.
|
|
19
|
+
- [x] Replace directory stale recovery with an internal exact reclaim marker that remains recoverable after claimant death; preserve live recovery guards and recover abandoned new-directory and legacy-file recovery guards with deterministic regressions.
|
|
20
|
+
- [x] Prove fail-soft diagnostics in a child process that emits one failing record under `--unhandled-rejections=strict`, independently from later queue recovery.
|
|
21
|
+
- [x] Run a clean follow-up review and preserve its `NOT READY` evidence for the reproduced same-process reclaim stall after a transient guard-rename failure.
|
|
22
|
+
- [x] Retry transient reclaim renames, roll the exact marker back on exhaustion, and treat inactive process-global reclaim generations as recoverable even when rollback itself fails; cover same-process reacquisition in both cases.
|
|
23
|
+
- [x] Run a final four-lens review and preserve its `NOT READY` evidence for peer-visible marker starvation and leaked replacement ownership after failed abandoned-recovery cleanup.
|
|
24
|
+
- [x] Retry rollback renames for peer-visible recovery, release exact newly recovered main ownership when secondary cleanup fails, and cover both failure sequences with deterministic same-process and child-process regressions.
|
|
25
|
+
- [x] Run one bounded final reviewer and preserve its `NOT READY` evidence for a reproduced stale-observation ABA race that let a delayed recoverer claim a replacement generation.
|
|
26
|
+
- [x] Bind each new directory owner generation into its unique `owner.<generation>.json` path and require filename/payload generation agreement, so delayed recovery receives `ENOENT` instead of renaming replacement metadata; cover the exact interleaving and repeat the child-process recovery barrier 20 times.
|
|
27
|
+
- [x] Obtain a final clean `READY` verdict and resolve every remaining release-blocking local finding.
|
|
28
|
+
- [x] Prepare `0.22.1` version metadata after review readiness, then rerun the complete release gate against the final package contents.
|
|
29
|
+
- [ ] Ask the issue #131 reporter to verify Termux startup, config/log writes, `/telegram-connect`, and reload after the hotfix becomes available; retain this as post-release environment evidence rather than a local release blocker.
|
|
30
|
+
|
|
31
|
+
Done when: pure Node filesystem operations provide exactly-one transaction ownership without hard links; old, replacement, and failed-recovery owners cannot delete or strand each other's guards; diagnostics failures cannot terminate Pi; independent review returns local `READY`; final release gates pass; and the post-release Termux verification request remains explicit until reporter evidence arrives.
|
|
4
32
|
|
|
5
33
|
## P1 — Promoted Follower Reload Evidence
|
|
6
34
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.22.1: Termux-Compatible Filesystem Transactions
|
|
6
|
+
|
|
7
|
+
- `Filesystem Transactions`: Replaced hard-link guard publication with private staged directories containing complete exact-owner metadata, atomically published each non-empty guard by same-parent rename, and atomically renamed exact-owned guards away before recursive cleanup. Bound each owner generation into a unique filename so delayed stale observations cannot claim replacement metadata, retained exact stale recovery through recoverable internal claimant markers, retried reclaim and rollback moves without peer-visible stalls, released newly recovered ownership when secondary cleanup fails, kept fail-closed malformed-state handling, and bounded legacy regular-file guard recovery. Impact: shared Telegram state no longer requires the hard-link operation rejected by Android/Termux, while exactly-one cross-process ownership remains locally enforced; live Termux confirmation remains post-release evidence.
|
|
8
|
+
- `Diagnostics`: Contained synchronous and queued JSONL persistence failures inside the diagnostics boundary while preserving later queued records after a failed append or rotation. Impact: an unavailable diagnostics path cannot terminate Pi through an unhandled rejection or permanently poison subsequent runtime evidence.
|
|
9
|
+
|
|
5
10
|
## 0.22.0: Concurrency And Runtime Ownership Hardening
|
|
6
11
|
|
|
7
12
|
- `Composition Root`: Reduced `index.ts` from 1,534 to 1,077 lines by moving transport generations, Threaded Mode orchestration, bus request identity, thread/status projections, exact message ownership, sync/provisioning state, session lifecycle sequencing, diagnostics persistence, Delivery policy, inbound target authority, follower forwarding, retry defaults, and leader identity state into their owning flat domains. The entrypoint now retains high-level runtime construction, narrow live-port wiring, and extension registration; direct one-to-one adapters remain only where another wrapper would hide rather than clarify composition. Impact: release-critical ownership and recovery policy can be tested beside its domain instead of accumulating in the extension bootstrap.
|
package/docs/architecture.md
CHANGED
|
@@ -81,7 +81,7 @@ The repository uses a **Flat Domain DAG**:
|
|
|
81
81
|
- `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
|
|
82
82
|
- `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
|
|
83
83
|
- `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, and photo/document delivery classification.
|
|
84
|
-
- `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, status snapshot scheduling, and grouped diagnostics. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections.
|
|
84
|
+
- `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, fail-soft synchronous and queued diagnostics persistence, status snapshot scheduling, and grouped diagnostics. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections and contains every persistence failure so diagnostics cannot terminate or poison the runtime queue.
|
|
85
85
|
- `lifecycle` / `prompts` / `prompt-templates` / `pi`: session-generation fencing and start/shutdown assembly across Queue, grouped input, Delivery, polling, capability monitor, watchdog, and follower refresh; Pi hook registration; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports.
|
|
86
86
|
- `command-templates`: shell-free command-template helpers, composition expansion, placeholder substitution, executable resolution, warnings, and retry/timeout semantics.
|
|
87
87
|
|
package/docs/locks.md
CHANGED
|
@@ -129,9 +129,13 @@ Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all par
|
|
|
129
129
|
|
|
130
130
|
## Atomicity
|
|
131
131
|
|
|
132
|
-
Every ownership mutation runs inside a short cross-process transaction acquired through
|
|
132
|
+
Every ownership mutation runs inside a short cross-process transaction acquired through an atomically published guard directory. The transaction covers the complete registry read/check/write sequence, so concurrent processes cannot both win an ordinary acquisition or stale-overwrite unrelated extension/profile keys. The registry payload itself still commits through same-directory temp-file replacement.
|
|
133
133
|
|
|
134
|
-
A guard
|
|
134
|
+
A guard starts as a private staged directory containing one fully written private `owner.<generation>.json` with the process PID, acquisition time, and matching collision-resistant generation. Renaming that non-empty directory to the stable per-resource transaction path publishes the complete guard atomically; an existing non-empty destination prevents another contender from replacing it. The generation-specific filename acts as the filesystem identity check: a delayed recoverer can rename only the exact owner path it observed, so replacement publication makes the stale source disappear instead of letting the old contender claim new metadata. Filename/payload generation disagreement fails closed. A crashed transaction owner therefore leaves verifiable metadata that a later process recovers only when the recorded PID is no longer alive.
|
|
135
|
+
|
|
136
|
+
Directory recovery first atomically renames the observed generation-specific owner file to a claimant-specific marker inside the still-occupied guard. Other contenders either observe the live claimant or, if it crashes, atomically take over that marker; the stable guard path never becomes free before one exact recoverer owns the transition. The claimant retries transient whole-guard rename failures and restores the previous exact marker on exhaustion. Process-global active-claim tracking also distinguishes a still-running reclaim from inactive same-process debris if restoration fails, so later acquisition does not require process exit. After the guard moves away, the claimant competes normally to publish a replacement. If cleanup of an abandoned adjacent recovery guard then fails, the process releases the exact replacement it just published before propagating the cleanup error, so failure cannot leave new live ownership stranded. Complete regular-file guards left by `0.22.0` use a separate directory recovery guard; abandoned directory recovery guards use the same internal-claim protocol, while abandoned legacy recovery files convert under a recoverable migration guard.
|
|
137
|
+
|
|
138
|
+
Release first compares the exact owner token, then atomically renames the stable guard away before recursively removing its directory and metadata. The stable transaction path therefore never exposes partial recursive cleanup as available ownership. New writers create only directory guards. Malformed directories, unsupported filesystem nodes, unverifiable owners, and failed atomic publication remain fail-closed after bounded contention rather than risking concurrent mutation.
|
|
135
139
|
|
|
136
140
|
Transactional reads treat only a missing registry as empty. Read, parse, and shape failures abort without replacing the existing file or erasing unrelated ownership keys.
|
|
137
141
|
|
package/lib/locks.ts
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
chmodSync,
|
|
8
9
|
existsSync,
|
|
9
|
-
|
|
10
|
+
lstatSync,
|
|
10
11
|
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
11
13
|
readFileSync,
|
|
14
|
+
readdirSync,
|
|
12
15
|
renameSync,
|
|
16
|
+
rmSync,
|
|
13
17
|
unlinkSync,
|
|
14
18
|
writeFileSync,
|
|
15
19
|
} from "node:fs";
|
|
16
20
|
import { randomUUID } from "node:crypto";
|
|
17
|
-
import { dirname } from "node:path";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
18
22
|
import { resolveTelegramLocksPath } from "./paths.ts";
|
|
19
23
|
|
|
20
24
|
export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
|
|
@@ -178,14 +182,37 @@ interface TelegramLockTransactionOwner {
|
|
|
178
182
|
generation: string;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
const TELEGRAM_TRANSACTION_OWNER_PATTERN =
|
|
186
|
+
/^owner\.([A-Za-z0-9-]+)\.json$/u;
|
|
187
|
+
|
|
188
|
+
function getLockTransactionOwnerFile(generation: string): string {
|
|
189
|
+
return `owner.${generation}.json`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getLockTransactionOwnerPath(path: string): string {
|
|
193
|
+
const stat = lstatSync(path);
|
|
194
|
+
if (stat.isDirectory()) {
|
|
195
|
+
const entries = readdirSync(path);
|
|
196
|
+
if (
|
|
197
|
+
entries.length === 1 &&
|
|
198
|
+
(TELEGRAM_TRANSACTION_OWNER_PATTERN.test(entries[0]) ||
|
|
199
|
+
TELEGRAM_TRANSACTION_RECLAIM_PATTERN.test(entries[0]))
|
|
200
|
+
) {
|
|
201
|
+
return join(path, entries[0]);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Unverifiable Telegram lock transaction guard: ${path}`);
|
|
204
|
+
}
|
|
205
|
+
if (stat.isFile()) return path;
|
|
206
|
+
throw new Error(`Unsupported Telegram lock transaction guard: ${path}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
181
209
|
function readLockTransactionOwner(
|
|
182
210
|
path: string,
|
|
183
211
|
): TelegramLockTransactionOwner | undefined {
|
|
184
212
|
try {
|
|
185
|
-
const value = JSON.parse(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
>;
|
|
213
|
+
const value = JSON.parse(
|
|
214
|
+
readFileSync(getLockTransactionOwnerPath(path), "utf8"),
|
|
215
|
+
) as Record<string, unknown>;
|
|
189
216
|
if (
|
|
190
217
|
typeof value.pid !== "number" ||
|
|
191
218
|
typeof value.acquiredAtMs !== "number" ||
|
|
@@ -193,6 +220,10 @@ function readLockTransactionOwner(
|
|
|
193
220
|
) {
|
|
194
221
|
return undefined;
|
|
195
222
|
}
|
|
223
|
+
const ownerMatch = TELEGRAM_TRANSACTION_OWNER_PATTERN.exec(
|
|
224
|
+
basename(getLockTransactionOwnerPath(path)),
|
|
225
|
+
);
|
|
226
|
+
if (ownerMatch && ownerMatch[1] !== value.generation) return undefined;
|
|
196
227
|
return {
|
|
197
228
|
pid: value.pid,
|
|
198
229
|
acquiredAtMs: value.acquiredAtMs,
|
|
@@ -203,6 +234,33 @@ function readLockTransactionOwner(
|
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
function createLockTransactionContentionError(path: string): Error {
|
|
238
|
+
return Object.assign(
|
|
239
|
+
new Error(`Telegram lock transaction guard already exists: ${path}`),
|
|
240
|
+
{ code: "EEXIST" },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isLockTransactionContentionError(
|
|
245
|
+
error: unknown,
|
|
246
|
+
path: string,
|
|
247
|
+
): boolean {
|
|
248
|
+
const code = (error as { code?: unknown })?.code;
|
|
249
|
+
if (
|
|
250
|
+
code === "EEXIST" ||
|
|
251
|
+
code === "ENOTEMPTY" ||
|
|
252
|
+
code === "ENOTDIR" ||
|
|
253
|
+
code === "EISDIR"
|
|
254
|
+
) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return existsSync(path) && (code === "EPERM" || code === "EACCES");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function removeLockTransactionGuard(path: string): void {
|
|
261
|
+
rmSync(path, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
|
|
206
264
|
function createLockTransactionGuard(
|
|
207
265
|
path: string,
|
|
208
266
|
): TelegramLockTransactionOwner {
|
|
@@ -211,18 +269,20 @@ function createLockTransactionGuard(
|
|
|
211
269
|
acquiredAtMs: Date.now(),
|
|
212
270
|
generation: randomUUID(),
|
|
213
271
|
};
|
|
214
|
-
const stagedPath = `${path}
|
|
272
|
+
const stagedPath = mkdtempSync(`${path}.staged.`);
|
|
215
273
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
274
|
+
chmodSync(stagedPath, 0o700);
|
|
275
|
+
writeFileSync(
|
|
276
|
+
join(stagedPath, getLockTransactionOwnerFile(owner.generation)),
|
|
277
|
+
`${JSON.stringify(owner)}\n`,
|
|
278
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
279
|
+
);
|
|
280
|
+
if (existsSync(path)) throw createLockTransactionContentionError(path);
|
|
281
|
+
renameSync(stagedPath, path);
|
|
222
282
|
return owner;
|
|
223
283
|
} finally {
|
|
224
284
|
try {
|
|
225
|
-
|
|
285
|
+
removeLockTransactionGuard(stagedPath);
|
|
226
286
|
} catch {
|
|
227
287
|
/* best effort */
|
|
228
288
|
}
|
|
@@ -247,13 +307,19 @@ function releaseLockTransactionGuard(
|
|
|
247
307
|
`Telegram lock transaction guard changed ownership: ${path}`,
|
|
248
308
|
);
|
|
249
309
|
}
|
|
310
|
+
const releasedPath = `${path}.released.${randomUUID()}`;
|
|
250
311
|
for (
|
|
251
312
|
let attempt = 0;
|
|
252
313
|
attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS;
|
|
253
314
|
attempt += 1
|
|
254
315
|
) {
|
|
255
316
|
try {
|
|
256
|
-
|
|
317
|
+
renameSync(path, releasedPath);
|
|
318
|
+
try {
|
|
319
|
+
removeLockTransactionGuard(releasedPath);
|
|
320
|
+
} catch {
|
|
321
|
+
/* released debris cannot retain transaction authority */
|
|
322
|
+
}
|
|
257
323
|
return;
|
|
258
324
|
} catch (error) {
|
|
259
325
|
if ((error as { code?: unknown })?.code === "ENOENT") return;
|
|
@@ -273,40 +339,254 @@ function isAbandonedLockTransaction(path: string): boolean {
|
|
|
273
339
|
return owner ? !isProcessAlive(owner.pid) : false;
|
|
274
340
|
}
|
|
275
341
|
|
|
276
|
-
|
|
342
|
+
const TELEGRAM_TRANSACTION_RECLAIM_PATTERN =
|
|
343
|
+
/^owner\.reclaim\.(\d+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/u;
|
|
344
|
+
const TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS = Symbol.for(
|
|
345
|
+
"@llblab/pi-telegram/active-transaction-reclaims",
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
type TelegramTransactionGlobal = typeof globalThis & {
|
|
349
|
+
[TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS]?: Set<string>;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export interface TelegramFileTransactionOptions {
|
|
353
|
+
recoveryRename?: typeof renameSync;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function getActiveTransactionReclaims(): Set<string> {
|
|
357
|
+
const root = globalThis as TelegramTransactionGlobal;
|
|
358
|
+
return (root[TELEGRAM_ACTIVE_TRANSACTION_RECLAIMS] ??= new Set());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function reclaimAbandonedDirectoryGuard(
|
|
277
362
|
path: string,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const recoveryGuardPath = `${path}.recovery`;
|
|
281
|
-
let recoveryOwner: TelegramLockTransactionOwner;
|
|
363
|
+
options: TelegramFileTransactionOptions = {},
|
|
364
|
+
): boolean {
|
|
282
365
|
try {
|
|
283
|
-
|
|
366
|
+
if (!lstatSync(path).isDirectory()) return false;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const entries = readdirSync(path);
|
|
371
|
+
if (entries.length !== 1) return false;
|
|
372
|
+
const entry = entries[0];
|
|
373
|
+
let observedPid: number;
|
|
374
|
+
let observedReclaimGeneration: string | undefined;
|
|
375
|
+
if (TELEGRAM_TRANSACTION_OWNER_PATTERN.test(entry)) {
|
|
376
|
+
const owner = readLockTransactionOwner(path);
|
|
377
|
+
if (!owner) return false;
|
|
378
|
+
observedPid = owner.pid;
|
|
379
|
+
} else {
|
|
380
|
+
const match = TELEGRAM_TRANSACTION_RECLAIM_PATTERN.exec(entry);
|
|
381
|
+
if (!match) return false;
|
|
382
|
+
observedPid = Number.parseInt(match[1], 10);
|
|
383
|
+
observedReclaimGeneration = match[2];
|
|
384
|
+
}
|
|
385
|
+
const activeReclaims = getActiveTransactionReclaims();
|
|
386
|
+
if (
|
|
387
|
+
observedPid === process.pid &&
|
|
388
|
+
observedReclaimGeneration !== undefined
|
|
389
|
+
) {
|
|
390
|
+
if (activeReclaims.has(observedReclaimGeneration)) return false;
|
|
391
|
+
} else if (isProcessAlive(observedPid)) {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const renameRecovery = options.recoveryRename ?? renameSync;
|
|
396
|
+
const sourcePath = join(path, entry);
|
|
397
|
+
const reclaimGeneration = randomUUID();
|
|
398
|
+
const reclaimPath = join(
|
|
399
|
+
path,
|
|
400
|
+
`owner.reclaim.${process.pid}.${reclaimGeneration}.json`,
|
|
401
|
+
);
|
|
402
|
+
try {
|
|
403
|
+
// Claim inside the still-occupied guard before making its stable path free.
|
|
404
|
+
renameRecovery(sourcePath, reclaimPath);
|
|
284
405
|
} catch (error) {
|
|
285
|
-
if ((error as { code?: unknown })?.code === "
|
|
406
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
286
407
|
throw error;
|
|
287
408
|
}
|
|
288
|
-
|
|
409
|
+
|
|
410
|
+
const renameWithRetry = (fromPath: string, toPath: string): boolean => {
|
|
411
|
+
for (
|
|
412
|
+
let attempt = 0;
|
|
413
|
+
attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS;
|
|
414
|
+
attempt += 1
|
|
415
|
+
) {
|
|
416
|
+
try {
|
|
417
|
+
renameRecovery(fromPath, toPath);
|
|
418
|
+
return true;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
421
|
+
if (
|
|
422
|
+
!isRetryableLockWriteError(error) ||
|
|
423
|
+
attempt === TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS - 1
|
|
424
|
+
) {
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
sleepSync(TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS * (attempt + 1));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
activeReclaims.add(reclaimGeneration);
|
|
434
|
+
const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
|
|
289
435
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
436
|
+
try {
|
|
437
|
+
if (!renameWithRetry(path, stalePath)) return false;
|
|
438
|
+
} catch (renameError) {
|
|
439
|
+
try {
|
|
440
|
+
if (!renameWithRetry(reclaimPath, sourcePath)) throw renameError;
|
|
441
|
+
} catch (rollbackError) {
|
|
442
|
+
throw new AggregateError(
|
|
443
|
+
[renameError, rollbackError],
|
|
444
|
+
`Failed to reclaim or restore Telegram lock transaction guard: ${path}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
throw renameError;
|
|
448
|
+
}
|
|
449
|
+
} finally {
|
|
450
|
+
activeReclaims.delete(reclaimGeneration);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
removeLockTransactionGuard(stalePath);
|
|
454
|
+
} catch {
|
|
455
|
+
/* stale debris cannot retain transaction authority */
|
|
456
|
+
}
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function acquireRecoverableDirectoryGuard(
|
|
461
|
+
path: string,
|
|
462
|
+
options: TelegramFileTransactionOptions = {},
|
|
463
|
+
): TelegramLockTransactionOwner | undefined {
|
|
464
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
465
|
+
try {
|
|
466
|
+
return createLockTransactionGuard(path);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (!isLockTransactionContentionError(error, path)) throw error;
|
|
469
|
+
if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function removeAbandonedLegacyRecoveryGuard(
|
|
476
|
+
path: string,
|
|
477
|
+
options: TelegramFileTransactionOptions = {},
|
|
478
|
+
): boolean {
|
|
479
|
+
try {
|
|
480
|
+
if (!lstatSync(path).isFile() || !isAbandonedLockTransaction(path))
|
|
481
|
+
return false;
|
|
482
|
+
} catch {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
const migrationGuardPath = `${path}.migration`;
|
|
486
|
+
const migrationOwner = acquireRecoverableDirectoryGuard(
|
|
487
|
+
migrationGuardPath,
|
|
488
|
+
options,
|
|
489
|
+
);
|
|
490
|
+
if (!migrationOwner) return false;
|
|
491
|
+
try {
|
|
492
|
+
try {
|
|
493
|
+
if (!lstatSync(path).isFile() || !isAbandonedLockTransaction(path))
|
|
494
|
+
return false;
|
|
495
|
+
} catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
|
|
292
499
|
try {
|
|
293
500
|
renameSync(path, stalePath);
|
|
294
501
|
} catch (error) {
|
|
295
|
-
if ((error as { code?: unknown })?.code === "ENOENT") return
|
|
502
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
296
503
|
throw error;
|
|
297
504
|
}
|
|
298
505
|
try {
|
|
299
|
-
|
|
506
|
+
removeLockTransactionGuard(stalePath);
|
|
300
507
|
} catch {
|
|
301
|
-
/*
|
|
508
|
+
/* stale debris cannot retain transaction authority */
|
|
302
509
|
}
|
|
510
|
+
return true;
|
|
511
|
+
} finally {
|
|
512
|
+
releaseLockTransactionGuard(migrationGuardPath, migrationOwner);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function acquireLegacyRecoveryGuard(
|
|
517
|
+
path: string,
|
|
518
|
+
options: TelegramFileTransactionOptions = {},
|
|
519
|
+
): TelegramLockTransactionOwner | undefined {
|
|
520
|
+
let owner = acquireRecoverableDirectoryGuard(path, options);
|
|
521
|
+
if (owner) return owner;
|
|
522
|
+
if (!removeAbandonedLegacyRecoveryGuard(path, options)) return undefined;
|
|
523
|
+
owner = acquireRecoverableDirectoryGuard(path, options);
|
|
524
|
+
return owner;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function createRecoveredLockTransactionGuard(
|
|
528
|
+
path: string,
|
|
529
|
+
): TelegramLockTransactionOwner | undefined {
|
|
530
|
+
try {
|
|
531
|
+
return createLockTransactionGuard(path);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (isLockTransactionContentionError(error, path)) return undefined;
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function recoverAbandonedLockTransaction(
|
|
539
|
+
path: string,
|
|
540
|
+
options: TelegramFileTransactionOptions = {},
|
|
541
|
+
): TelegramLockTransactionOwner | undefined {
|
|
542
|
+
if (!isAbandonedLockTransaction(path)) return undefined;
|
|
543
|
+
let isDirectory: boolean;
|
|
544
|
+
try {
|
|
545
|
+
isDirectory = lstatSync(path).isDirectory();
|
|
546
|
+
} catch {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
if (isDirectory) {
|
|
550
|
+
if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
|
|
551
|
+
const recoveredOwner = createRecoveredLockTransactionGuard(path);
|
|
303
552
|
try {
|
|
304
|
-
|
|
553
|
+
reclaimAbandonedDirectoryGuard(`${path}.recovery`, options);
|
|
305
554
|
return recoveredOwner;
|
|
306
555
|
} catch (error) {
|
|
307
|
-
if (
|
|
556
|
+
if (recoveredOwner) {
|
|
557
|
+
try {
|
|
558
|
+
releaseLockTransactionGuard(path, recoveredOwner);
|
|
559
|
+
} catch {
|
|
560
|
+
/* preserve the recovery cleanup failure */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
308
563
|
throw error;
|
|
309
564
|
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const recoveryGuardPath = `${path}.recovery`;
|
|
568
|
+
const recoveryOwner = acquireLegacyRecoveryGuard(
|
|
569
|
+
recoveryGuardPath,
|
|
570
|
+
options,
|
|
571
|
+
);
|
|
572
|
+
if (!recoveryOwner) return undefined;
|
|
573
|
+
let recoveredOwner: TelegramLockTransactionOwner | undefined;
|
|
574
|
+
try {
|
|
575
|
+
if (!isAbandonedLockTransaction(path)) return undefined;
|
|
576
|
+
const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
|
|
577
|
+
try {
|
|
578
|
+
renameSync(path, stalePath);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return undefined;
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
removeLockTransactionGuard(stalePath);
|
|
585
|
+
} catch {
|
|
586
|
+
/* stale debris cannot retain transaction authority */
|
|
587
|
+
}
|
|
588
|
+
recoveredOwner = createRecoveredLockTransactionGuard(path);
|
|
589
|
+
return recoveredOwner;
|
|
310
590
|
} finally {
|
|
311
591
|
try {
|
|
312
592
|
releaseLockTransactionGuard(recoveryGuardPath, recoveryOwner);
|
|
@@ -323,7 +603,10 @@ function recoverAbandonedLockTransaction(
|
|
|
323
603
|
}
|
|
324
604
|
}
|
|
325
605
|
|
|
326
|
-
function acquireLockTransaction(
|
|
606
|
+
function acquireLockTransaction(
|
|
607
|
+
path: string,
|
|
608
|
+
options: TelegramFileTransactionOptions = {},
|
|
609
|
+
): TelegramLockTransactionOwner {
|
|
327
610
|
mkdirSync(dirname(path), { recursive: true });
|
|
328
611
|
for (
|
|
329
612
|
let attempt = 0;
|
|
@@ -333,8 +616,8 @@ function acquireLockTransaction(path: string): TelegramLockTransactionOwner {
|
|
|
333
616
|
try {
|
|
334
617
|
return createLockTransactionGuard(path);
|
|
335
618
|
} catch (error) {
|
|
336
|
-
if ((error
|
|
337
|
-
const recoveredOwner = recoverAbandonedLockTransaction(path);
|
|
619
|
+
if (!isLockTransactionContentionError(error, path)) throw error;
|
|
620
|
+
const recoveredOwner = recoverAbandonedLockTransaction(path, options);
|
|
338
621
|
if (recoveredOwner !== undefined) return recoveredOwner;
|
|
339
622
|
if (attempt === TELEGRAM_LOCK_TRANSACTION_ATTEMPTS - 1) {
|
|
340
623
|
throw new Error(
|
|
@@ -350,8 +633,9 @@ function acquireLockTransaction(path: string): TelegramLockTransactionOwner {
|
|
|
350
633
|
export function withTelegramFileTransaction<T>(
|
|
351
634
|
transactionPath: string,
|
|
352
635
|
operation: () => T,
|
|
636
|
+
options: TelegramFileTransactionOptions = {},
|
|
353
637
|
): T {
|
|
354
|
-
const owner = acquireLockTransaction(transactionPath);
|
|
638
|
+
const owner = acquireLockTransaction(transactionPath, options);
|
|
355
639
|
try {
|
|
356
640
|
return operation();
|
|
357
641
|
} finally {
|
package/lib/logs.ts
CHANGED
|
@@ -159,7 +159,6 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
159
159
|
const path = resolvePath();
|
|
160
160
|
const previousPath = resolvePreviousPath();
|
|
161
161
|
pending = pending
|
|
162
|
-
.catch(() => undefined)
|
|
163
162
|
.then(() => {
|
|
164
163
|
ensureParent(path);
|
|
165
164
|
withTelegramFileTransaction(`${path}.transaction`, () => {
|
|
@@ -178,7 +177,8 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
178
177
|
}
|
|
179
178
|
appendFileSync(path, line, { mode: 0o600 });
|
|
180
179
|
});
|
|
181
|
-
})
|
|
180
|
+
})
|
|
181
|
+
.catch(() => undefined);
|
|
182
182
|
};
|
|
183
183
|
|
|
184
184
|
return {
|
|
@@ -203,7 +203,11 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
203
203
|
}
|
|
204
204
|
},
|
|
205
205
|
record(event) {
|
|
206
|
-
|
|
206
|
+
try {
|
|
207
|
+
appendLine(safeJsonLine({ kind: "event", ...event }) + "\n");
|
|
208
|
+
} catch {
|
|
209
|
+
// Diagnostics must never break Telegram runtime behavior.
|
|
210
|
+
}
|
|
207
211
|
},
|
|
208
212
|
};
|
|
209
213
|
}
|