Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/telegram-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ The setup pairing flow is private-chat only. If setup sees a `group`,
DM. This is intentional for safe local discovery: group chats must not receive
session names, action ids, or pending status by accident.


Telegram private-chat topics: the managed daemon's per-session delivery uses
Telegram forum topics (`createForumTopic` + `message_thread_id`). Telegram now
supports forum topics in **private chats** when the bot owner enables **Threaded
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@

- User-created Telegram forum topics can now start a GJC session by selecting the home folder, choosing a verified recent work folder, or entering an explicit folder path. The selected topic is adopted by the new session without creating or deleting a separate Telegram topic.
- The interactive terminal’s responsive IRC/todo work-lane contract now covers exact narrow/wide geometry, requested versus effective IRC visibility, direct-root pin ordering, todo lane bounds, remapped IRC toggles, and live composer shortcut hints.
- Managed-session startup now preserves bounded Windows ACL and identity failure classifications in path-redacted recovery guidance without broadening permissions, elevation, or unsafe fallback.
- Telegram topic synchronization now uses generation-CAS shared authority, durable pre-create claims, lease-fenced effects, bounded single-flight archive retries, and an isolated owner-backed validation-supergroup mode without deleting topics.

### Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const LOCKED_EXCLUSIONS: Readonly<Record<string, string>> = {
"agent_session:constructor": "internal accessor/plumbing, not a user-facing control seam",
"agent_session:registerToolSessionCleanup":
"internal tool lifecycle cleanup registration, not a user-facing SDK control seam",
"agent_session:registerToolSessionTransitionCleanup":
"internal tool transition cleanup registration, not a user-facing SDK control seam",
"agent_session:nextToolChoice": "internal accessor/plumbing, not a user-facing control seam",
"agent_session:setForcedToolChoice": "internal accessor/plumbing, not a user-facing control seam",
"agent_session:getActiveSkillState": "internal accessor/plumbing, not a user-facing control seam",
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/config/file-lock-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ function keptMalformedRecord(lockDir: string): GcRecord {
async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
const info = await readFileLockInfoForGc(lockDir);
if (!info) return keptMalformedRecord(lockDir);
if (info.owner_host_id !== undefined) {
return {
store: "file_locks",
id: lockDir,
path: lockDir,
pid: info.pid,
pid_status: "unknown",
status: "host_qualified",
stale: false,
removable: false,
action: "none",
reason: "host_qualified_lock_requires_owner_reclamation",
detail: `timestamp=${info.timestamp}`,
};
}

const probeResult = ctx.probe(info.pid);
const pidStatus = gcPidStatusLabel(probeResult);
Expand Down Expand Up @@ -166,6 +181,9 @@ export const fileLocksGcAdapter: GcStoreAdapter = {
const lockDir = record.path ?? record.id;
const info = await readFileLockInfoForGc(lockDir);
if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
if (info.owner_host_id !== undefined) {
return { removed: false, skipped: "host_qualified_lock_requires_owner_reclamation" };
}

const probeResult = ctx.probe(info.pid);
if (probeResult.status !== "dead") {
Expand Down
77 changes: 61 additions & 16 deletions packages/coding-agent/src/config/file-lock.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as crypto from "node:crypto";
import type { Stats } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
Expand All @@ -7,9 +8,11 @@ export interface FileLockOptions {
staleMs?: number;
retries?: number;
retryDelayMs?: number;
/** Stable host identity required to safely reclaim locks on a shared volume. */
ownerHostId?: string;
}

const DEFAULT_OPTIONS: Required<FileLockOptions> = {
const DEFAULT_OPTIONS: Required<Omit<FileLockOptions, "ownerHostId">> = {
staleMs: 10_000,
retries: 50,
retryDelayMs: 100,
Expand Down Expand Up @@ -61,8 +64,16 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
return currentStartTime === null || currentStartTime === owner.start_time;
}

function writeLockInfo(lockPath: string): Promise<LockInfo> {
const info: LockInfo = { pid: process.pid, start_time: currentProcessStartTime(), timestamp: Date.now() };
function lockInfo(ownerHostId?: string): LockInfo {
return {
pid: process.pid,
start_time: currentProcessStartTime(),
timestamp: Date.now(),
...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
};
}

function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
return Bun.write(`${lockPath}/info`, JSON.stringify(info)).then(() => info);
}

Expand All @@ -76,17 +87,18 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
}

if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
const { pid, start_time, timestamp } = parsed as Partial<LockInfo>;
const { pid, start_time, timestamp, owner_host_id } = parsed as Partial<LockInfo>;
if (
typeof pid !== "number" ||
!Number.isInteger(pid) ||
pid <= 0 ||
typeof timestamp !== "number" ||
!Number.isFinite(timestamp) ||
(start_time !== undefined && (typeof start_time !== "string" || !start_time))
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id))
)
return null;
return { pid, start_time, timestamp };
return { pid, start_time, timestamp, owner_host_id };
}

/** @internal */
Expand All @@ -98,7 +110,7 @@ export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOw
export interface FileLockOwnerToken {
pid: number;
start_time?: string;

owner_host_id?: string;
timestamp: number;
}

Expand Down Expand Up @@ -147,6 +159,7 @@ export async function removeFileLockDirForGc(
if (
current.pid !== expected.pid ||
(expected.start_time !== undefined && current.start_time !== expected.start_time) ||
current.owner_host_id !== expected.owner_host_id ||
current.timestamp !== expected.timestamp
) {
return "owner_changed";
Expand Down Expand Up @@ -187,6 +200,7 @@ function sameStatToken(a: LockDirStatToken, b: LockDirStatToken): boolean {
async function staleLockSnapshot(
lockPath: string,
staleMs: number,
ownerHostId?: string,
startTimeCache?: Map<string, string | null>,
): Promise<LockStaleSnapshot> {
let info: LockInfo | null;
Expand All @@ -199,6 +213,7 @@ async function staleLockSnapshot(
if (hasFsCode(error, "EPERM")) return { stale: false };
throw error;
}
if (!info && ownerHostId !== undefined) return { stale: false };
if (!info) {
try {
const stats = await fs.stat(lockPath);
Expand All @@ -210,6 +225,10 @@ async function staleLockSnapshot(
}
}

// A host-qualified lock may only be reclaimed after proving that its owner is
// local. Foreign and malformed host-qualified records fail closed: PID values
// and clocks are not meaningful across hosts.
if (ownerHostId !== undefined && info.owner_host_id !== ownerHostId) return { stale: false };
// Never reap a live owner by elapsed time: a long legitimate critical section must
// not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
// liveness is indeterminate do we fall back to the staleMs elapsed-time heuristic.
Expand Down Expand Up @@ -239,18 +258,43 @@ async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSn
}
}

async function tryAcquireLock(lockPath: string): Promise<LockInfo | null> {
async function tryAcquireLock(lockPath: string, ownerHostId?: string): Promise<LockInfo | null> {
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
if (afterParentMkdir) await afterParentMkdir(lockPath);
if (ownerHostId === undefined) {
try {
await fs.mkdir(lockPath);
return await writeLockInfo(lockPath, lockInfo());
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
throw error;
}
}

const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
const owner = lockInfo(ownerHostId);
try {
await fs.mkdir(lockPath);
return await writeLockInfo(lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
return null;
await fs.mkdir(pendingPath);
await writeLockInfo(pendingPath, owner);
try {
await fs.rename(pendingPath, lockPath);
return owner;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" || code === "ENOTEMPTY") return null;
if (code === "EPERM") {
try {
await fs.stat(lockPath);
return null;
} catch (statError) {
if (!isEnoent(statError)) throw statError;
}
}
throw error;
}
throw error;
} finally {
await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
}
}

Expand All @@ -259,14 +303,15 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise
if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`);
}
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
if (options.ownerHostId !== undefined && !options.ownerHostId) throw new Error("ownerHostId must be non-empty");
const opts = { ...DEFAULT_OPTIONS, ...options };
const lockPath = getLockPath(filePath);
const contentionStartTimes = new Map<string, string | null>();
for (let attempt = 0; attempt < opts.retries; attempt++) {
const owner = await tryAcquireLock(lockPath);
const owner = await tryAcquireLock(lockPath, opts.ownerHostId);
if (owner) return () => releaseLock(lockPath, owner);

const stale = await staleLockSnapshot(lockPath, opts.staleMs, contentionStartTimes);
const stale = await staleLockSnapshot(lockPath, opts.staleMs, opts.ownerHostId, contentionStartTimes);
if (await removeStaleLockForAcquire(lockPath, stale)) continue;
await Bun.sleep(opts.retryDelayMs);
}
Expand Down

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
import {
type DaemonState,
FilesystemTopicRegistryCasAuthority,
loadInstallationHostId,
readDaemonState,
readOwnerFreshnessSnapshot,
type TelegramDaemonOptions,
Expand Down Expand Up @@ -45,6 +47,8 @@ export interface RunDaemonInternalDeps {
clearInterval?: (timer: Timer) => void;
/** Reads persisted daemon ownership state; defaults to the real reader. */
readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
/** Loads the verified machine-local identity; injectable so daemon tests do not touch the host. */
loadInstallationHostId?: () => Promise<string>;
}

/** Ownership-watchdog cadence while the daemon process is running. */
Expand Down Expand Up @@ -231,6 +235,11 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
const cfg = getNotificationConfig(settings);
if (!isProviderEffectivelyEnabled(cfg, "telegram") || !isTelegramComplete(cfg)) return;
const installationHostId = await (deps.loadInstallationHostId ?? loadInstallationHostId)();
const topicRegistryAuthority = new FilesystemTopicRegistryCasAuthority(
path.join(daemonPaths(resolvedAgentDir).dir, "telegram-topics.json"),
{ installationHostId },
);
const Daemon: TelegramDaemonConstructor = deps.DaemonImpl ?? TelegramNotificationDaemon;
const readState = deps.readDaemonState ?? readDaemonState;
const daemon = new Daemon({
Expand All @@ -247,6 +256,8 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
btw: cfg.btw,
pid: deps.processPid ?? process.pid,
control: createDaemonControlHooks(settings as Settings),
topicRegistryAuthority,
installationHostId,
});
// Signals are a process concern: install them at the daemon-internal boundary,
// not inside the embeddable daemon class. SIGTERM is the reload wakeup path.
Expand Down
15 changes: 9 additions & 6 deletions packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
* ownership, settlement, and descendant cleanup authority. Generation 38 also
* adds durable provider-intent admission without changing owner, reclaim,
* signal, or spawn authority.
* Generation 41 applies first-class provider-settings admission to Telegram
* lifecycle controls, plus cross-host topic-registry CAS convergence, host-and-epoch
* archive fencing, retained topic history, user-topic adoption provenance, and
* exact versionless shared-state upgrades with quarantined source snapshots.
* Generation 42 applies first-class provider-settings admission to Telegram
* lifecycle controls. Generation 43 applies identity-bound exact replacement
* cleanup shared by managed-session and daemon filesystem authority. Generation
Expand All @@ -68,12 +72,11 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
* Generation 49 drains every admitted session-message handler before final
* durable persistence and ownership release.
*/
export const DAEMON_GENERATION = 49;
export const DAEMON_GENERATION = 50;

/**
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers
* all builds published before this field existed; epoch 2 covered generation 29;
* epoch 3 covered generation 30; bump this to force serving convergence and
* reload of compatible live predecessors.
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
* requires the complete generation-36 topic authority contract, so older
* epoch-4 daemons cannot keep serving across an upgrade.
*/
export const SERVING_EPOCH = 4;
export const SERVING_EPOCH = 5;
Loading
Loading