import { ApiClient } from './api-client'; import type { RepoSyncResult } from './repo-sync'; import type { TransportKind } from './ipc-types'; import type { AgentChatMode, ProjectRegistration } from './types'; export interface ProjectAgentOptions { pollInterval: number; heartbeatInterval: number; } export declare class ProjectAgent { private readonly agentId; private readonly options; private readonly onAuthRejected?; private readonly client; private prefix; private tenantCode; private projectDir; private readonly apiUrl; private token; private projectCode; private readonly configSyncState; private configSyncDeps; private readonly transportState; private transportDeps; private isRegistering; private registerLoopCancelled; private registerAttempt; private registerAbortController; /** In-flight register loop, awaited by restartRegisterLoop() before restarting. */ private registerLoopPromise; /** Incremented on every stop() so a deferred restart can detect a later stop. */ private stopGeneration; /** * Admission mode for the next register call. * * `'initial'` normally. Set to `'standby'` by eviction recovery: a replica * that was evicted must NOT re-register as `'initial'`, because the server * treats `'initial'` as "you may evict the oldest live replica" — the evicted * replica would evict whoever took its slot, that one would do the same, and * the two would swap forever (ping-pong). Sticky across register retries so a * transient failure between eviction and re-admission cannot silently * downgrade it back to `'initial'`. */ private nextAdmissionMode; /** Resolver that cuts the standby wait short (set only while parked). */ private standbyWakeup; private lastRegisterError; private alertPollingTimer; private alertStaleRecoveryTimer; /** * Retries results that failed to reach the API (see `startPendingResultFlush`). * Without this the retry only ran at process start, so a result orphaned by an * API deployment sat on disk until the agent happened to restart. */ private pendingResultFlushTimer; /** * Whether the unconditional start-up recovery has already run. * * `registerAndStart()` runs again on token update and on eviction → re-admission, * and `stopTransport` deliberately does NOT clear `inFlightCommands` — commands * already running survive a transport restart. So on a re-registration a result * may be mid-submit on the main path, and sweeping it up unconditionally would * re-introduce the parallel double-POST that PENDING_RESULT_MIN_RETRY_AGE_MS exists * to prevent. Only the very first registration is guaranteed to have nothing in flight. */ private hasRecoveredPendingResults; /** * Guards shutdown() against double-invocation (e.g. SIGTERM+SIGINT both * firing, or the auto-updater's stopAllAgents racing a signal handler). * Kept as a plain boolean (flipped true synchronously by the first call, * alongside `shutdownPromise` below) because several call sites * (`handleEviction()`, `updateToken()`) only need a synchronous "is a * shutdown in progress" check, not the in-flight promise itself. */ private shuttingDown; /** * The in-flight `doShutdown()` promise, once a first `shutdown()` call has * started one. `shutdown()` is a thin memoizing wrapper around * `doShutdown()`: a second concurrent caller joins (awaits) this same * promise instead of getting an instant no-op — see `shutdown()`'s doc * comment for why an early-return no-op is wrong here (it would let a * second caller like the auto-updater proceed as if the drain/release had * already finished, while the first caller's drain of a genuinely * in-flight command is still running). */ private shutdownPromise; /** * Whether this replica currently holds an admitted slot. Set true as soon * as `register()`/`performRegistration()` reports `admission.accepted` * (i.e. as soon as the server-side truth is known — see `registerAndStart`), * set false again by handleEviction(). shutdown() skips releaseSelf() when * this is false — a replica that was rejected/evicted holds nothing to * release. */ private slotHeld; /** * Command ids that must not count as "still in flight" for * `waitForDrain()`'s purposes, because they are themselves the command * driving the current shutdown (reboot/update/docker-rebuild) and cannot be * removed from `transportState.inFlightCommands` until the very `shutdown()` * call they triggered resolves — see `shutdown()`'s doc comment. * * A `Set` rather than a single captured value: `shutdown()` adds to this set * *before* checking/returning the memoized `shutdownPromise`, so a caller * that joins an already-in-flight shutdown (e.g. `performReboot()` calling * `shutdown({ excludeCommandId })` after a plain SIGTERM-triggered * `shutdown()` already started the drain) still gets its exclusion honored. * A single opts-snapshot captured only by the first caller would silently * discard every later caller's `excludeCommandId`, reintroducing the very * self-wait deadlock this mechanism exists to prevent (see the regression * this fixes). */ private readonly excludedCommandIds; constructor(project: ProjectRegistration, agentId: string, options: ProjectAgentOptions, localAgentChatMode?: AgentChatMode, defaultProjectDir?: string, onAuthRejected?: ((transport: TransportKind) => void) | undefined); start(): void; stop(): void; /** * Cancel the register/admission loop: mark it cancelled, abort any pending * retry sleep, and wake a parked standby wait so the loop unwinds now * instead of up to REPLICA_STANDBY_RETRY_DELAY_MS later. * * Split out of `stop()` so `shutdown()` can cancel the register loop and * alert timers (see `clearAlertTimers`) up front, before draining, without * also stopping the transport — the transport must keep running through the * drain (heartbeats + any in-flight command's websocket dependency). */ private cancelRegisterLoop; private clearAlertTimers; /** * Stop everything this replica does on behalf of its slot: transport * (AppSync subscription, WebSockets, heartbeat) and the alert polling timers. * * Used by both `stop()` and `handleEviction()`. Eviction previously stopped * only the transport, leaving the alert timers running — a replica that had * handed its slot to another one kept polling and processing alerts, which * both duplicates work and contradicts "a standby replica does no work". */ private stopWork; /** * Gracefully shut down: drain in-flight commands, then release this * replica's slot, before finally stopping the transport. Used by * SIGTERM/SIGINT and by the restart paths (reboot/update/docker rebuild) * instead of the synchronous `stop()`, so a command that is still executing * is never abandoned mid-flight — abandoning it and releasing the slot early * would let the server re-assign the command to another replica while this * one is still running it, executing it twice. * * Order matters: * 1. Guard against double-invocation (SIGTERM+SIGINT both firing). * 2. Mark the transport as draining so no *new* command is accepted. * 3. Cancel the register/admission loop and alert timers, but deliberately * do NOT stop the transport yet: heartbeats must keep running through the * drain (a) to keep the slot's lastHeartbeat fresh so the server does not * consider it dead mid-drain, and (b) because an in-flight command may * depend on the transport's websocket (e.g. e2e/browser-driven commands * via the VS Code tunnel) that would otherwise be yanked out from under it. * 4. (handled by `handleEviction` checking `shuttingDown`) — heartbeats keep * running, so the server could still reply `evicted: true` for unrelated * reasons during the drain; that must not re-enter the standby loop while * this process is already on its way out. * 5. Poll-wait for in-flight commands to drain. * 6. Release the slot — but only if the drain actually completed (not timed * out: the process is going to die either way if it timed out, and * releasing while a command might still be running would be wrong) and * only if this replica ever held a slot in the first place. * 7. Stop the transport (same as the transport-stopping half of `stop()`). * * `opts.excludeCommandId`: the id of the command that is *itself* triggering * this shutdown (reboot/update). `processCommand` only removes a command's id * from `transportState.inFlightCommands` in a `finally` block that runs after * its handler (e.g. `performReboot`/`performUpdate`, which calls `shutdown()`) * has returned — so the triggering command's own id is still present in * `inFlightCommands` for the entire duration of this call. Without excluding * it, the drain below would wait for it to disappear, which can only happen * after `shutdown()` itself resolves: a deadlock that blocks for the full * drain timeout on every single reboot/update. SIGTERM/SIGINT-triggered * shutdowns pass no `excludeCommandId` — there is no in-flight command * driving those, so this is a no-op for that path. * * `excludeCommandId` is added to the shared `excludedCommandIds` set * *before* the memoization check below, not threaded through as a * parameter captured only by whichever caller happens to arrive first — see * `excludedCommandIds`'s doc comment for why: any caller that joins an * already-in-flight shutdown must still get its own exclusion honored. * * Concurrent-caller semantics: two independent call sites in * `runSingleProject()` (`src/agent-runner.ts`) can both call `shutdown()` * around the same time — the SIGTERM/SIGINT handler and the auto-updater's * `stopAllAgents` callback. A second caller here JOINS the first caller's * in-flight `doShutdown()` (awaits the same promise) rather than getting an * instant no-op; otherwise the second caller would proceed (e.g. the * auto-updater calling `reExecProcess()`) while the first caller's drain of * a genuinely in-flight command is still running, abandoning it mid-flight. * The second caller's `drainTimeoutMs` is ignored once a shutdown is already * in flight — the first caller's timeout wins, since restarting the drain * with a different timeout mid-flight doesn't make sense. `excludeCommandId` * is the one exception: it is always honored, from whichever caller * supplies it, via the shared set. */ shutdown(opts?: { drainTimeoutMs?: number; excludeCommandId?: string; }): Promise; private doShutdown; /** * Poll-wait for `transportState.inFlightCommands` to drain, using the same * interruptible/unref'd-timer poll style as the standby wait * (`waitInterruptible`) rather than a plain `sleep()`, so the wait never * holds the event loop open past process exit. * * Ids present in `this.excludedCommandIds` do not count toward "still in * flight": each is the id of a command (reboot/update/docker-rebuild) that * is itself driving a (possibly shared) shutdown, and it cannot be removed * from `inFlightCommands` until that command's own `shutdown()` call * returns — see `shutdown()`'s and `excludedCommandIds`'s doc comments for * why. The set is read live on every poll iteration (not snapshotted at the * start of the drain), so an exclusion added moments after this loop * started — e.g. by a caller joining an already-in-flight shutdown — is * picked up on the next iteration (within `SHUTDOWN_DRAIN_POLL_INTERVAL_MS`). */ private waitForDrain; /** * Restart the register loop after a `stop()` issued by this class itself * (token update / eviction recovery). * * `start()` is a no-op while `isRegistering` is true, so a bare * `setImmediate(() => this.start())` silently drops the restart whenever the * loop is parked on a long await — most notably the standby wait, which can * hold for the full retry delay. Losing the restart leaves the agent idle * forever. Waiting for the previous loop's promise guarantees the restart * lands after `isRegistering` has flipped back to false. * * The generation check prevents this deferred restart from resurrecting the * agent when a genuine external `stop()` (shutdown) arrives in the meantime. */ private restartRegisterLoop; isBusy(): boolean; getClient(): ApiClient; updateToken(newToken: string): void; /** * @param commandId The id of the `config_sync` command that triggered this * call, when invoked via the command dispatch path (`onConfigSync`). * Threaded through to `performDockerRebuild()` (via `onDockerRebuild`) so * its `shutdown()` call can exclude this still-in-flight command from the * drain wait — see `ConfigSyncDeps.onDockerRebuild`'s doc comment. * `undefined` for background syncs (initial startup retry loop, debounced * `config-update` notifications). */ performConfigSync(commandId?: string): Promise; /** * @param commandId The id of the `setup` command that triggered this call. * See `performConfigSync`'s doc comment — same threading, since `setup` * performs a config sync internally and can trigger the same * `onDockerRebuild` path. */ performSetup(commandId?: string): Promise; performSyncRepository(repositoryCode: string, branch?: string): Promise; /** * @param commandId The id of the 'reboot' command that triggered this call * (when invoked via the command dispatch path). Passed through to * `shutdown()` as `excludeCommandId` so the drain does not wait on this * very command's own entry in `inFlightCommands` — see `shutdown`'s doc * comment. Direct callers (e.g. tests) may omit it; `shutdown()` then * drains normally, which is a no-op when nothing is in flight. */ performReboot(commandId?: string): Promise; /** * @param commandId The id of the `config_sync` (or `setup`) command whose * handler is still on the call stack when this fires — `performConfigSync` * / `performSetup` invoke `onDockerRebuild` synchronously from inside * `applyProjectConfig`, and `void`-call this method fire-and-forget, so * this method's own `shutdown()` call below can run before that * triggering command's handler has returned and `processCommand` * (agent-transport.ts) has removed it from `inFlightCommands` in its * `finally` block. Passed through to `shutdown()` as `excludeCommandId` * for the same self-reference reason as `performReboot`/`performUpdate` — * without it, the drain below would needlessly wait on the triggering * command's own in-flight entry (up to the full drain timeout in the * worst case) before finally giving up and proceeding anyway. * `undefined` when there is no specific triggering command (e.g. the * initial startup config sync in `registerAndStart()`, run before any * command has ever been dispatched), which `shutdown()`/`waitForDrain()` * already handle correctly. */ performDockerRebuild(commandId?: string): Promise; /** * @param commandId The id of the 'update' command that triggered this call * (when invoked via the command dispatch path). See `performReboot`'s doc * comment — same self-reference reason for threading it into `shutdown()`. */ performUpdate(commandId?: string): Promise; private registerAndStart; /** * Emit a one-time diagnostic warning for admission rejection reasons that * need operator attention beyond the generic "waiting for a slot" log * (`runner.replicaStandby`, emitted unconditionally by waitForAdmission for * every rejection). Called once, before entering the standby loop — not on * every standby retry — so it does not repeat on each re-request. * * `limit_reached` needs no extra explanation (the standby log already says * exactly what it is: the plan's replica limit). `instance_id_conflict` is * the case that needs a distinct, actionable log: it means another *live* * process is already registered under this same instanceId, which the * generic standby-wait log would otherwise make indistinguishable from an * ordinary "the plan is full" wait — see the 2026-08-15 incident where * identical Pod names across two Kubernetes clusters made the server treat * two separate processes as one replica reconnecting. */ private logAdmissionRejectionReason; /** * Wait in standby until a replica slot frees up, then return the accepted * register response. * * Re-requests admission with `admissionMode: 'standby'`, which the server * admits only into a **free** slot. It deliberately never evicts on a * standby's behalf: if it did, an evicted replica would immediately evict * whoever took its place and the replicas would swap forever. * * No transport is started while waiting, so a standby replica holds no * WebSocket, receives no commands, and consumes no slot. */ private waitForAdmission; /** * Wait before the next standby admission attempt, interruptibly. * * A plain `sleep()` would keep the register loop parked for the whole delay, * so `stop()` (shutdown, token update) could not unwind it promptly and the * follow-up restart would be dropped. `stop()` calls `wakeStandbyWait()` to * cut this short. */ private waitBeforeStandbyRetry; /** * Jittered variant of REPLICA_STANDBY_RETRY_DELAY_MS: uniformly distributed * in [0.5x, 1x] of the constant (same "equal jitter" shape as * `calculateBackoff()` in retry-strategy.ts, applied here to a flat retry * interval rather than a growing exponential backoff). * * Dozens of replicas that start (and get rejected) at roughly the same * moment — a Kubernetes rolling restart or mass deployment — would * otherwise all poll the server's admission lock in lockstep every 30s. * Jittering each replica's own next wait independently means they drift * apart across repeated retry cycles instead of staying synchronized. */ private jitteredStandbyRetryDelay; /** * `stop()` で打ち切れる待機。タイマーは `unref()` してイベントループを * 押さえないようにする(素の `setTimeout` はシャットダウン後もプロセスの * 終了を最大で待機時間ぶん遅らせる)。 */ private waitInterruptible; /** Cut a parked standby wait short (no-op when not waiting). */ private wakeStandbyWait; /** * Called when a heartbeat reports this replica was evicted (a newer replica * took its slot). Stops all work and re-enters admission as a **standby**. * * Re-registering as `'initial'` here would break the ping-pong invariant: * the server lets `'initial'` evict the oldest live replica, so this replica * would immediately evict the one that just took its slot, that one would * come back the same way, and the two would swap forever. `'standby'` only * ever enters a free slot. */ private handleEviction; /** * Calls the register API, updates local state from the response, and * performs any Docker-specific post-registration tasks (writing the * registered-agent-id marker and reporting a docker-build-error if present). * * Throws on failure so the caller's retry loop can apply exponential backoff. * * @param admissionMode `initial` on process start (may evict the oldest * replica when the plan limit is reached); `standby` while waiting for a * free slot (never evicts). */ private performRegistration; /** * Starts all transport-layer services using the completed register response: * AppSync subscription, heartbeat, CloudWatch alert polling, and terminal/VS Code WebSocket. * * Throws if the AppSync URL is absent so the caller's retry loop retries the * whole registration flow (the URL may appear once a server-side rollout * completes). The agent authenticates to AppSync with its own agent token * (`this.token`) via the Lambda authorizer, so the master API key is no * longer required here. */ private startServices; private runRegisterLoop; private cancellableSleep; } //# sourceMappingURL=project-agent.d.ts.map