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 packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
- A failed `notify setup` no longer reports "Unable to persist and activate Telegram notification settings" when the durable configuration already carries the attempted bot token, chat id, and enabled state. The wording now follows the stored configuration, so it can no longer contradict a follow-up `notify status`; an operator who reads the failure as "nothing was saved" would otherwise leave Telegram armed for a token another poller may own. A commit that was entered and then failed while the stored configuration is also unreadable is reported as undecided, pointing at `notify status`, instead of guessing either outcome (#3761).
- Continuing a large managed session on Darwin now batches stale OpenAI Responses replay-metadata patches into one transcript append instead of performing one identity-verified whole-file replacement per patch. Interactive startup also renders before exact MCP connection and explicit `--mpreset` activation, gates every provider turn until both are ready, and refreshes models online only after the UI is usable, preventing `gjc -c` from remaining at `GJC warming workspace` with sustained CPU, multi-gigabyte RSS growth, or avoidable network waits (#3793).
- Slack Web API requests now use form encoding instead of JSON, preventing thread reconciliation through `conversations.replies` from failing with `invalid_arguments`.
- Every pre-readiness exit of the detached Telegram notification daemon child now records a credential-free one-line reason on its own stderr, which the launcher already redirects into `notifications/daemon.log`, plus a `daemon pid <pid>` notice once ownership reaches `ready`. Previously a child that refused startup — dead owner pid, a `config.yml` the child cannot use, an unreadable settings source, a blank bot token, or any ownership-admission refusal inside `renewDaemonHeartbeat` — exited with status 0, an empty `daemon.log`, and a `logger` line it never flushed, so a failed activation was indistinguishable from a daemon that was never spawned. `renewDaemonHeartbeat` reports the exact refusing condition instead of a bare `false`; the guard itself is unchanged, and the owner id (which doubles as the acquisition secret) and the bot token are never written. Daemon generation is 50 (#3761).

- Managed replacement cleanup now migrates version-one receipts from earlier releases and recovers canonical exchange placeholders left by interrupted cleanup, so a stale receipt cannot permanently block the next managed session mutation with `managed_replace_cleanup_receipt_invalid`.

Expand Down
48 changes: 48 additions & 0 deletions packages/coding-agent/src/sdk/bus/daemon-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { logger } from "@gajae-code/utils";

/** Destination for one-line daemon startup diagnostics; defaults to process stderr. */
export type DaemonDiagnosticSink = (line: string) => void;

export const DAEMON_DIAGNOSTIC_PREFIX = "gjc notify daemon:";

/** `<id>:<secret>` bot tokens must never reach a durable log file. */
const BOT_TOKEN = /\b\d{6,}:[A-Za-z0-9_-]{20,}\b/g;

/** Collapses a reason to one credential-free line. */
export function sanitizeDaemonDiagnostic(reason: string): string {
return reason.replace(BOT_TOKEN, "<redacted-token>").replace(/\s+/g, " ").trim();
}

/**
* Records why a daemon child exited (or that it reached readiness). `logger`
* alone is not enough: a child which exits during startup never flushes that
* sink, which is why these exits were indistinguishable from "never started"
* (#3761). The daemon-internal entrypoint therefore passes
* {@link stderrDaemonDiagnosticSink}, whose output the launcher redirects into
* `notifications/daemon.log`. Embedded callers pass their own sink, so the
* daemon class never writes to a host process's stderr on its own.
*/
export function recordDaemonStartupDiagnostic(reason: string, sink?: DaemonDiagnosticSink): void {
emit("warn", reason, sink);
}

/** Same durable channel as {@link recordDaemonStartupDiagnostic}, for non-failure milestones. */
export function recordDaemonStartupNotice(reason: string, sink?: DaemonDiagnosticSink): void {
emit("info", reason, sink);
}

/** Writes one timestamped line to stderr, which a daemon child has redirected into its log. */
export const stderrDaemonDiagnosticSink: DaemonDiagnosticSink = line => {
process.stderr.write(`${new Date().toISOString()} ${line}\n`);
};

function emit(level: "warn" | "info", reason: string, sink?: DaemonDiagnosticSink): void {
const line = `${DAEMON_DIAGNOSTIC_PREFIX} ${sanitizeDaemonDiagnostic(reason)}`;
if (level === "warn") logger.warn(line);
else logger.info(line);
try {
sink?.(line);
} catch {
// A diagnostic must never be able to break the exit path it describes.
}
}
48 changes: 37 additions & 11 deletions packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { logger } from "@gajae-code/utils";
import { YAML } from "bun";
import { applyAtomicYamlPatches, setByPath } from "../../config/atomic-yaml-patch";
import type { Settings } from "../../config/settings";
Expand All @@ -11,6 +10,11 @@ import {
type NotificationSettingsReader,
parseNotificationSettingsSnapshot,
} from "./config";
import {
type DaemonDiagnosticSink,
recordDaemonStartupDiagnostic,
stderrDaemonDiagnosticSink,
} from "./daemon-diagnostics";
import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
import {
type DaemonState,
Expand Down Expand Up @@ -49,6 +53,8 @@ export interface RunDaemonInternalDeps {
readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
/** Loads the verified machine-local identity; injectable so daemon tests do not touch the host. */
loadInstallationHostId?: () => Promise<string>;
/** Durable one-line diagnostic sink; defaults to the child's stderr. */
diagnostic?: DaemonDiagnosticSink;
}

/** Ownership-watchdog cadence while the daemon process is running. */
Expand All @@ -59,13 +65,6 @@ function argValue(argv: string[], name: string): string | undefined {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
}
const DAEMON_COMPATIBILITY_DIAGNOSTIC_LIMIT = 1;
let daemonCompatibilityDiagnosticCount = 0;
function recordDaemonCompatibilityDiagnostic(message: string): void {
if (daemonCompatibilityDiagnosticCount >= DAEMON_COMPATIBILITY_DIAGNOSTIC_LIMIT) return;
daemonCompatibilityDiagnosticCount++;
logger.warn(message);
}

export function createLightweightDaemonSettings(input: {
agentDir: string;
Expand Down Expand Up @@ -227,14 +226,40 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
if (smoke) return runDaemonSmoke({ agentDir });
const ownerId = argValue(argv, "--owner-id");
if (!ownerId) throw new Error("missing --owner-id");
// This is the daemon child's process boundary: its stderr is the fd the
// launcher redirected into notifications/daemon.log (#3761).
const diagnostic = deps.diagnostic ?? stderrDaemonDiagnosticSink;
if (!ownerProcessIsAlive(ownerId, deps)) {
recordDaemonCompatibilityDiagnostic("GJC notify daemon exiting because its owner is not alive");
recordDaemonStartupDiagnostic(
// The owner id carries the acquisition secret: report only its pid.
`exiting before startup: owner process ${ownerPidFromOwnerId(ownerId) ?? "(no pid in owner id)"} is not alive`,
diagnostic,
);
return;
}
const resolvedAgentDir = agentDir ?? process.env.GJC_CODING_AGENT_DIR ?? path.join(process.cwd(), ".gjc", "agent");
const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
let settings: LightweightDaemonSettings;
try {
settings = await resolveDaemonSettings(resolvedAgentDir, deps);
} catch (error) {
// The child owns no terminal: an unreported load failure is invisible.
recordDaemonStartupDiagnostic(
`exiting before startup: cannot load notification settings from ${resolvedAgentDir}: ${String(error)}`,
diagnostic,
);
throw error;
}
const cfg = getNotificationConfig(settings);
if (!isProviderEffectivelyEnabled(cfg, "telegram") || !isTelegramComplete(cfg)) return;
if (!isProviderEffectivelyEnabled(cfg, "telegram") || !isTelegramComplete(cfg)) {
// The child reads config.yml directly, so a parent that considers Telegram
// configured through the full Settings stack can still spawn a child which
// sees an unconfigured provider. Name which half refused (#3761).
recordDaemonStartupDiagnostic(
`exiting before startup: ${path.join(resolvedAgentDir, "config.yml")} does not enable a complete Telegram provider (effectively enabled: ${isProviderEffectivelyEnabled(cfg, "telegram")}, credentials complete: ${isTelegramComplete(cfg)})`,
diagnostic,
);
return;
}
const installationHostId = await (deps.loadInstallationHostId ?? loadInstallationHostId)();
const topicRegistryAuthority = new FilesystemTopicRegistryCasAuthority(
path.join(daemonPaths(resolvedAgentDir).dir, "telegram-topics.json"),
Expand All @@ -258,6 +283,7 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
control: createDaemonControlHooks(settings as Settings),
topicRegistryAuthority,
installationHostId,
diagnostic,
});
// 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 @@ -75,13 +75,16 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
* final-component file symlinks fail-closed under AT_SYMLINK_NOFOLLOW (bounded
* #3761 multi-account activation repair). Generation 51 adds shared durable
* topic authority, archive recovery, and requires Telegram's documented error
* code for idempotent archive settlement. Generation 52 is claimed by the
* pre-readiness daemon-child exit diagnostics slice (#3761). Generation 53
* renders multi-select state for ask-tool asks, not only durable workflow
* gates, and renumbers pre-numbered options exactly once around the selection
* marker.
* code for idempotent archive settlement. Generation 52 was reserved for the
* exit-diagnostics slice below and left unpublished when that slice landed
* after #3899, so no daemon ever served it. Generation 53 renders multi-select
* state for ask-tool asks, not only durable workflow gates, and renumbers
* pre-numbered options exactly once around the selection marker. Generation 54
* makes every pre-readiness daemon-child exit report a durable, credential-free
* reason on the child's stderr (captured into `notifications/daemon.log`) and
* publishes a readiness notice (#3761).
*/
export const DAEMON_GENERATION = 53;
export const DAEMON_GENERATION = 54;

/**
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
Expand Down
Loading
Loading