{"version":3,"file":"execution-heartbeat.d.ts","sourceRoot":"","sources":["../../../src/core/mission-domain/execution-heartbeat.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Execution Heartbeat (2.8.0).\n *\n * Active lease maintenance for a single execution ownership lifetime.\n *\n * The durable execution lease protects *authoritative* mutation, but by itself\n * it cannot stop a long-running executor (local Qwen, tools, tests) from\n * continuing to burn resources after its lease expires and another process\n * takes ownership. This module is the runtime half of that contract:\n *\n *   - It renews the lease on a safe cadence while the owning execution is\n *     healthy, without ever changing the fencing token.\n *   - It classifies renewal outcomes into SUCCESS, TRANSIENT retryable failure,\n *     or DEFINITE ownership loss.\n *   - It aborts the owning execution through an injected authority-loss\n *     callback when ownership is lost or can no longer be proven before the\n *     lease safety margin.\n *\n * The heartbeat is deliberately a *local* runtime object owned by the\n * coordinator's execution lifetime. It is never a global/background timer and\n * never a daemon: it starts only after durable RUNNING ownership is confirmed\n * and stops exactly once on completion, cancellation, launch failure, authority\n * loss, or an unhandled execution error.\n */\n\nimport { ExecutionOwnershipError } from \"./execution-lease.js\";\n\n// =============================================================================\n// Timing policy\n// =============================================================================\n\nexport interface HeartbeatTimingInput {\n\t/** Lease lifetime used by the coordinator/store. Must be a positive safe integer. */\n\tleaseDurationMs: number;\n\t/**\n\t * Renewal cadence. Defaults to `leaseDurationMs / 3`, i.e. three renewals\n\t * per lease lifetime — enough to survive a single missed renewal while\n\t * keeping filesystem churn negligible relative to LLM execution.\n\t */\n\theartbeatIntervalMs?: number;\n\t/**\n\t * Safety window before expiry inside which a renewal can no longer be\n\t * trusted. Defaults to `leaseDurationMs / 6`. If the heartbeat reaches this\n\t * window without a confirmed renewal it aborts conservatively rather than\n\t * continue work on an expiring lease.\n\t */\n\trenewalSafetyMarginMs?: number;\n}\n\nexport interface ResolvedHeartbeatTiming {\n\tleaseDurationMs: number;\n\theartbeatIntervalMs: number;\n\trenewalSafetyMarginMs: number;\n}\n\n/**\n * Validate and normalize heartbeat timing. Invalid configurations must never\n * silently create unsafe behavior (a heartbeat that can never run, or one that\n * only fires after the lease is already expiring).\n */\nexport function resolveHeartbeatTiming(input: HeartbeatTimingInput): ResolvedHeartbeatTiming {\n\tconst { leaseDurationMs } = input;\n\tif (!Number.isSafeInteger(leaseDurationMs) || leaseDurationMs <= 0) {\n\t\tthrow new Error(`heartbeat leaseDurationMs must be a positive safe integer, got ${String(leaseDurationMs)}`);\n\t}\n\tconst heartbeatIntervalMs = input.heartbeatIntervalMs ?? Math.floor(leaseDurationMs / 3);\n\tconst renewalSafetyMarginMs = input.renewalSafetyMarginMs ?? Math.floor(leaseDurationMs / 6);\n\n\tif (!Number.isSafeInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {\n\t\tthrow new Error(`heartbeatIntervalMs must be a positive safe integer, got ${String(heartbeatIntervalMs)}`);\n\t}\n\tif (!Number.isSafeInteger(renewalSafetyMarginMs) || renewalSafetyMarginMs < 0) {\n\t\tthrow new Error(\n\t\t\t`renewalSafetyMarginMs must be a non-negative safe integer, got ${String(renewalSafetyMarginMs)}`,\n\t\t);\n\t}\n\tif (renewalSafetyMarginMs >= leaseDurationMs) {\n\t\tthrow new Error(\n\t\t\t`renewalSafetyMarginMs (${renewalSafetyMarginMs}) must be less than leaseDurationMs (${leaseDurationMs})`,\n\t\t);\n\t}\n\tif (heartbeatIntervalMs >= leaseDurationMs - renewalSafetyMarginMs) {\n\t\tthrow new Error(\n\t\t\t`heartbeatIntervalMs (${heartbeatIntervalMs}) must be less than leaseDurationMs - renewalSafetyMarginMs ` +\n\t\t\t\t`(${leaseDurationMs - renewalSafetyMarginMs}) so a renewal always lands inside the safe window`,\n\t\t);\n\t}\n\n\treturn { leaseDurationMs, heartbeatIntervalMs, renewalSafetyMarginMs };\n}\n\n// =============================================================================\n// Scheduling seam\n// =============================================================================\n\nexport interface HeartbeatTimer {\n\tclear(): void;\n}\n\n/**\n * Injectable timer scheduling (default: `setTimeout`). Recursive scheduling is\n * used so at most one renewal is ever in flight and ticks never overlap.\n */\nexport type HeartbeatScheduler = (fn: () => void, delayMs: number) => HeartbeatTimer;\n\nexport const defaultHeartbeatScheduler: HeartbeatScheduler = (fn, delayMs) => {\n\tconst handle = setTimeout(fn, delayMs);\n\treturn { clear: () => clearTimeout(handle) };\n};\n\n// =============================================================================\n// Renewal classification\n// =============================================================================\n\nexport type HeartbeatRenewalFailureKind = \"DEFINITE_OWNERSHIP_LOSS\" | \"TRANSIENT_RENEWAL_FAILURE\";\n\n/**\n * Classify a renewal error. Only structured, authoritative ownership-loss codes\n * are DEFINITE; everything else (short filesystem contention, temporary lock\n * timeouts, unexpected I/O) is TRANSIENT and may be retried while lease\n * validity still remains.\n */\nexport function classifyHeartbeatRenewalError(error: unknown): HeartbeatRenewalFailureKind {\n\tif (error instanceof ExecutionOwnershipError) {\n\t\tswitch (error.code) {\n\t\t\tcase \"STALE_EXECUTION_OWNER\":\n\t\t\tcase \"LEASE_NOT_FOUND\":\n\t\t\tcase \"LEASE_EXPIRED\":\n\t\t\t\treturn \"DEFINITE_OWNERSHIP_LOSS\";\n\t\t\tdefault:\n\t\t\t\treturn \"TRANSIENT_RENEWAL_FAILURE\";\n\t\t}\n\t}\n\treturn \"TRANSIENT_RENEWAL_FAILURE\";\n}\n\n// =============================================================================\n// Authority loss\n// =============================================================================\n\nexport type HeartbeatAuthorityLossReason =\n\t| \"EXECUTION_AUTHORITY_LOST\"\n\t| \"HEARTBEAT_LEASE_EXPIRING\"\n\t| \"HEARTBEAT_RENEWAL_FAILED\";\n\nexport interface HeartbeatAuthorityLossInfo {\n\treason: HeartbeatAuthorityLossReason;\n\tmessage: string;\n\tatMs: number;\n\tdetail?: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Internal execution-authority-loss abstraction. Distinct from the existing\n * store/ownership errors (`STALE_EXECUTION_OWNER`, etc.): those describe a\n * fenced store mutation, whereas this describes the local runtime decision to\n * stop because the execution can no longer prove it owns the lease.\n */\nexport class ExecutionAuthorityLostError extends Error {\n\treadonly code = \"EXECUTION_AUTHORITY_LOST\" as const;\n\treadonly reason: HeartbeatAuthorityLossReason;\n\treadonly detail?: Readonly<Record<string, unknown>>;\n\n\tconstructor(info: HeartbeatAuthorityLossInfo) {\n\t\tsuper(info.message);\n\t\tthis.name = \"ExecutionAuthorityLostError\";\n\t\tthis.reason = info.reason;\n\t\tthis.detail = info.detail;\n\t}\n}\n\n// =============================================================================\n// Telemetry\n// =============================================================================\n\nexport interface HeartbeatTelemetry {\n\theartbeatActive: boolean;\n\theartbeatIntervalMs: number;\n\tlastRenewalAt?: number;\n\tleaseExpiresAt: number;\n\trenewalCount: number;\n\trenewalFailureCount: number;\n\tlastRenewalError?: string;\n\tauthorityLost: boolean;\n\tauthorityLostAt?: number;\n\tauthorityLostReason?: HeartbeatAuthorityLossReason;\n}\n\n// =============================================================================\n// Heartbeat\n// =============================================================================\n\nexport interface ExecutionHeartbeatDeps {\n\tmissionId: string;\n\tleaseId: string;\n\tfencingToken: number;\n\ttiming: ResolvedHeartbeatTiming;\n\tnow: () => number;\n\tschedule: HeartbeatScheduler;\n\t/**\n\t * Resolve with the renewed lease expiry. Reject with an\n\t * `ExecutionOwnershipError` (or an unexpected runtime error) on failure.\n\t */\n\trenew: (now: number) => Promise<{ expiresAtMs: number }>;\n\t/** Called exactly once when ownership is lost or can no longer be proven. */\n\tonAuthorityLost: (info: HeartbeatAuthorityLossInfo) => void;\n}\n\nexport class ExecutionHeartbeat {\n\tprivate readonly _deps: ExecutionHeartbeatDeps;\n\tprivate _active = false;\n\tprivate _timer: HeartbeatTimer | undefined;\n\tprivate _expiresAtMs = 0;\n\tprivate _lastRenewalAt: number | undefined;\n\tprivate _renewalCount = 0;\n\tprivate _renewalFailureCount = 0;\n\tprivate _lastRenewalError: string | undefined;\n\tprivate _authorityLost = false;\n\tprivate _authorityLostAt: number | undefined;\n\tprivate _authorityLostReason: HeartbeatAuthorityLossReason | undefined;\n\n\tconstructor(deps: ExecutionHeartbeatDeps) {\n\t\tthis._deps = deps;\n\t}\n\n\tget missionId(): string {\n\t\treturn this._deps.missionId;\n\t}\n\n\tget leaseId(): string {\n\t\treturn this._deps.leaseId;\n\t}\n\n\tget fencingToken(): number {\n\t\treturn this._deps.fencingToken;\n\t}\n\n\t/** Begin renewal after the initial ownership has been durably confirmed. */\n\tstart(initialExpiresAtMs: number): void {\n\t\tif (this._active) return;\n\t\tthis._active = true;\n\t\tthis._expiresAtMs = initialExpiresAtMs;\n\t\tthis._scheduleNext();\n\t}\n\n\t/** Idempotent stop. Never invokes the authority-loss callback. */\n\tstop(): void {\n\t\tif (!this._active && !this._timer) return;\n\t\tthis._active = false;\n\t\tthis._clearTimer();\n\t}\n\n\ttelemetry(): HeartbeatTelemetry {\n\t\treturn {\n\t\t\theartbeatActive: this._active,\n\t\t\theartbeatIntervalMs: this._deps.timing.heartbeatIntervalMs,\n\t\t\tlastRenewalAt: this._lastRenewalAt,\n\t\t\tleaseExpiresAt: this._expiresAtMs,\n\t\t\trenewalCount: this._renewalCount,\n\t\t\trenewalFailureCount: this._renewalFailureCount,\n\t\t\tlastRenewalError: this._lastRenewalError,\n\t\t\tauthorityLost: this._authorityLost,\n\t\t\tauthorityLostAt: this._authorityLostAt,\n\t\t\tauthorityLostReason: this._authorityLostReason,\n\t\t};\n\t}\n\n\tprivate _scheduleNext(): void {\n\t\tif (!this._active) return;\n\t\tthis._clearTimer();\n\t\tthis._timer = this._deps.schedule(() => {\n\t\t\tthis._timer = undefined;\n\t\t\tvoid this._tick();\n\t\t}, this._deps.timing.heartbeatIntervalMs);\n\t}\n\n\tprivate _clearTimer(): void {\n\t\tif (this._timer) {\n\t\t\tthis._timer.clear();\n\t\t\tthis._timer = undefined;\n\t\t}\n\t}\n\n\tprivate async _tick(): Promise<void> {\n\t\tif (!this._active) return;\n\t\tconst now = this._deps.now();\n\t\tconst remaining = this._expiresAtMs - now;\n\n\t\tif (remaining <= this._deps.timing.renewalSafetyMarginMs) {\n\t\t\tthis._triggerAuthorityLost({\n\t\t\t\treason: \"HEARTBEAT_LEASE_EXPIRING\",\n\t\t\t\tmessage: `Cannot renew lease for mission ${this._deps.missionId} before the safety margin`,\n\t\t\t\tatMs: now,\n\t\t\t\tdetail: { remainingMs: remaining, expiresAtMs: this._expiresAtMs },\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst renewed = await this._deps.renew(now);\n\t\t\tif (!this._active) return;\n\t\t\tthis._expiresAtMs = renewed.expiresAtMs;\n\t\t\tthis._lastRenewalAt = now;\n\t\t\tthis._renewalCount += 1;\n\t\t\tthis._lastRenewalError = undefined;\n\t\t\tthis._scheduleNext();\n\t\t} catch (error) {\n\t\t\tif (!this._active) return;\n\n\t\t\tconst kind = classifyHeartbeatRenewalError(error);\n\t\t\tif (kind === \"DEFINITE_OWNERSHIP_LOSS\") {\n\t\t\t\tthis._triggerAuthorityLost({\n\t\t\t\t\treason: \"EXECUTION_AUTHORITY_LOST\",\n\t\t\t\t\tmessage: `Execution authority for mission ${this._deps.missionId} was lost`,\n\t\t\t\t\tatMs: now,\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tleaseId: this._deps.leaseId,\n\t\t\t\t\t\tfencingToken: this._deps.fencingToken,\n\t\t\t\t\t\terrorCode: error instanceof ExecutionOwnershipError ? error.code : undefined,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis._renewalFailureCount += 1;\n\t\t\tthis._lastRenewalError = error instanceof Error ? error.message : String(error);\n\n\t\t\tconst remainingAfter = this._expiresAtMs - this._deps.now();\n\t\t\tif (remainingAfter <= this._deps.timing.renewalSafetyMarginMs) {\n\t\t\t\tthis._triggerAuthorityLost({\n\t\t\t\t\treason: \"HEARTBEAT_RENEWAL_FAILED\",\n\t\t\t\t\tmessage: `Heartbeat renewal failed and lease validity can no longer be confirmed for mission ${this._deps.missionId}`,\n\t\t\t\t\tatMs: this._deps.now(),\n\t\t\t\t\tdetail: { remainingMs: remainingAfter, lastError: this._lastRenewalError },\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis._scheduleNext();\n\t\t}\n\t}\n\n\tprivate _triggerAuthorityLost(info: HeartbeatAuthorityLossInfo): void {\n\t\tif (this._authorityLost) return;\n\t\tthis._authorityLost = true;\n\t\tthis._authorityLostAt = info.atMs;\n\t\tthis._authorityLostReason = info.reason;\n\t\tthis._active = false;\n\t\tthis._clearTimer();\n\t\tthis._deps.onAuthorityLost(info);\n\t}\n}\n"]}