@seawork/server 2.0.2 → 2.0.3

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.
Files changed (26) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +3 -0
  2. package/dist/server/server/agent/agent-manager.d.ts.map +1 -1
  3. package/dist/server/server/agent/agent-manager.js +22 -1
  4. package/dist/server/server/agent/agent-manager.js.map +1 -1
  5. package/dist/server/server/agent/agent-projections.d.ts.map +1 -1
  6. package/dist/server/server/agent/agent-projections.js +4 -0
  7. package/dist/server/server/agent/agent-projections.js.map +1 -1
  8. package/dist/server/server/agent/agent-storage.d.ts +86 -0
  9. package/dist/server/server/agent/agent-storage.d.ts.map +1 -1
  10. package/dist/server/server/agent/agent-storage.js +16 -0
  11. package/dist/server/server/agent/agent-storage.js.map +1 -1
  12. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts +13 -4
  13. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts.map +1 -1
  14. package/dist/server/server/agent/providers/codex-app-server-agent.js +241 -23
  15. package/dist/server/server/agent/providers/codex-app-server-agent.js.map +1 -1
  16. package/dist/server/server/agent/providers/codex-rollout-timeline.d.ts.map +1 -1
  17. package/dist/server/server/agent/providers/codex-rollout-timeline.js +11 -2
  18. package/dist/server/server/agent/providers/codex-rollout-timeline.js.map +1 -1
  19. package/dist/server/server/agent/user-message-image-attachments.d.ts +11 -0
  20. package/dist/server/server/agent/user-message-image-attachments.d.ts.map +1 -0
  21. package/dist/server/server/agent/user-message-image-attachments.js +114 -0
  22. package/dist/server/server/agent/user-message-image-attachments.js.map +1 -0
  23. package/dist/server/server/session.d.ts.map +1 -1
  24. package/dist/server/server/session.js +6 -2
  25. package/dist/server/server/session.js.map +1 -1
  26. package/package.json +5 -4
@@ -24,7 +24,20 @@ const TURN_START_TIMEOUT_MS = 90 * 1000;
24
24
  // stalled and force-complete it. Codex normally streams item/reasoning/output
25
25
  // events continuously, so a healthy long-running turn re-arms the watchdog far
26
26
  // more often than this; only a turn whose `turn/completed` is lost goes silent.
27
- const TURN_WATCHDOG_IDLE_MS = 5 * 60 * 1000;
27
+ //
28
+ // issue #1886: must stay STRICTLY GREATER than codex's own `stream_idle_timeout`
29
+ // (DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5min). codex logs `Request completed` when the
30
+ // /responses *response headers* arrive — which is when #1824's upstream-liveness
31
+ // re-arm fires — but then reads the SSE *body* stream, and an upstream that sends
32
+ // 200 headers and then stalls the body emits no further stderr and no protocol
33
+ // notification. codex's stream_idle_timeout is the correct authority for that
34
+ // stall (it retries / surfaces an error, both of which re-arm us), so the daemon
35
+ // watchdog must give it a full idle window plus a buffer before tripping. At
36
+ // 5min == 5min the two timers raced from the same "headers arrived" instant and
37
+ // the daemon won, force-failing a turn codex was still legitimately waiting on.
38
+ // 6min lets codex's idle_timeout fire first; the daemon only steps in if codex
39
+ // itself goes silent (process wedged / lost notification).
40
+ const TURN_WATCHDOG_IDLE_MS = 6 * 60 * 1000;
28
41
  // issue #1427: when the watchdog decides a turn is stalled, do NOT force-fail
29
42
  // immediately. A successful turn's `turn/completed` can arrive tens of ms after
30
43
  // the watchdog fires — codex emits it only after a serial flush_rollout +
@@ -125,7 +138,8 @@ const SEAWORK_AUTO_APPROVE_TOOLS = [
125
138
  "generation_models",
126
139
  "generation_task",
127
140
  ];
128
- const CODEX_IMAGE_ATTACHMENT_DIR = "seawork-attachments";
141
+ const CODEX_EPHEMERAL_IMAGE_ATTACHMENT_DIR = "seawork-attachments";
142
+ const CODEX_IMAGE_ATTACHMENT_DIR = "codex-image-attachments";
129
143
  const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = "The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
130
144
  // codex's experimental `goals` feature ships in 0.128.0+. Older binaries reject
131
145
  // `--enable goals`, so we version-gate both the launch flag and the /goal
@@ -593,18 +607,6 @@ function toCodexMcpConfig(config) {
593
607
  };
594
608
  }
595
609
  }
596
- function summarizeJsonRpcParamsForLog(params) {
597
- if (params && typeof params === "object" && !Array.isArray(params)) {
598
- return { paramKeys: Object.keys(params).sort() };
599
- }
600
- if (params === null) {
601
- return { paramType: "null" };
602
- }
603
- if (Array.isArray(params)) {
604
- return { paramType: "array" };
605
- }
606
- return { paramType: typeof params };
607
- }
608
610
  // Pull the gateway `x-request-id` out of codex's `codex_client::default_client:
609
611
  // Request completed ... url=.../responses ... headers={... "x-request-id": "..." ...}`
610
612
  // debug line. Only `/responses` (model turn) requests are matched so a `/models`
@@ -620,6 +622,37 @@ export function extractResponsesRequestId(logLine) {
620
622
  }
621
623
  return line.match(/"x-request-id":\s*"([0-9a-fA-F-]+)"/)?.[1] ?? null;
622
624
  }
625
+ // DIAGNOSTIC (watchdog mis-kill): pull the identifying fields out of a codex
626
+ // notification's params for logging, without dumping the (potentially huge)
627
+ // full payload. Surfaces the turn/item identity so a gap in a specific turn's
628
+ // lifecycle notifications is attributable in daemon.log.
629
+ function summarizeJsonRpcParamsForLog(params) {
630
+ if (!params || typeof params !== "object")
631
+ return {};
632
+ if (Array.isArray(params))
633
+ return { paramType: "array" };
634
+ const p = params;
635
+ const out = {};
636
+ for (const key of ["turnId", "turn_id", "itemId", "item_id", "id", "type", "status"]) {
637
+ if (p[key] !== undefined && (typeof p[key] === "string" || typeof p[key] === "number")) {
638
+ out[key] = p[key];
639
+ }
640
+ }
641
+ // codex nests the turn id under turn:{id} / item:{id} in some notifications.
642
+ const turn = p.turn;
643
+ if (turn && typeof turn.id === "string")
644
+ out.turnId = turn.id;
645
+ const item = p.item;
646
+ if (item && typeof item.id === "string")
647
+ out.itemId = item.id;
648
+ if (item && typeof item.type === "string")
649
+ out.itemType = item.type;
650
+ // Fallback for unknown object shapes: surface key names only (no values) so
651
+ // the log entry is attributable without leaking payload content.
652
+ if (Object.keys(out).length === 0)
653
+ return { paramKeys: Object.keys(p).sort() };
654
+ return out;
655
+ }
623
656
  class CodexAppServerClient {
624
657
  constructor(child, logger) {
625
658
  this.child = child;
@@ -631,9 +664,24 @@ class CodexAppServerClient {
631
664
  this.upstreamLivenessHandler = null;
632
665
  this.nextId = 1;
633
666
  this.disposed = false;
667
+ // DIAGNOSTIC (watchdog mis-kill): per-method lifetime counts of notifications
668
+ // seen off codex stdout, and per-delta-method last-logged time for rollups.
669
+ this.notificationLineCounts = new Map();
670
+ this.notificationDeltaLastLoggedAt = new Map();
634
671
  this.stderrBuffer = "";
635
672
  this.stderrLineBuf = "";
673
+ // DIAGNOSTIC (watchdog mis-kill): separate line-assembly buffer for mirroring
674
+ // codex stderr (RUST_LOG output) into daemon.log; kept apart from stderrLineBuf
675
+ // which the responses-request-id scraper consumes.
676
+ this.stderrLogLineBuf = "";
636
677
  this.lastResponsesRequestId = null;
678
+ // issue #1886 DIAGNOSTIC: wall-clock of the last stderr byte from codex. Even at
679
+ // the default `error` RUST_LOG level codex emits *something* while alive (reqwest
680
+ // debug, retries), so when the watchdog trips, a fresh value here means codex is
681
+ // alive-but-not-notifying (gap is codex→daemon / SSE body stall) while a stale
682
+ // one means codex itself went silent (process wedged / upstream death) — the
683
+ // distinction the force-fail diagnostic could not previously make.
684
+ this.stderrLastActivityAt = null;
637
685
  this.resolveExitPromise = null;
638
686
  this.rl = readline.createInterface({ input: child.stdout });
639
687
  this.exitPromise = new Promise((resolve) => {
@@ -642,11 +690,31 @@ class CodexAppServerClient {
642
690
  this.rl.on("line", (line) => this.handleLine(line));
643
691
  child.stderr.on("data", (chunk) => {
644
692
  const text = chunk.toString();
693
+ this.stderrLastActivityAt = Date.now();
645
694
  this.stderrBuffer += text;
646
695
  if (this.stderrBuffer.length > 8192) {
647
696
  this.stderrBuffer = this.stderrBuffer.slice(-8192);
648
697
  }
649
698
  this.captureResponsesRequestId(text);
699
+ // DIAGNOSTIC (watchdog mis-kill): the stderr buffer is a rolling 8KB tail
700
+ // kept only for crash reports, so codex's RUST_LOG output (the only window
701
+ // into codex-internal turn/event lifecycle) was otherwise discarded. Mirror
702
+ // it line-by-line into daemon.log at debug level so that, when [notif-rx]
703
+ // shows zero notifications during a stall, we can still see whether codex
704
+ // core emitted the event and whether the app-server tried to write it —
705
+ // i.e. attribute a notification gap to codex vs the daemon. Lines are
706
+ // already RUST_LOG-gated upstream, so volume tracks the configured level.
707
+ this.stderrLogLineBuf += text;
708
+ let nlIdx;
709
+ while ((nlIdx = this.stderrLogLineBuf.indexOf("\n")) >= 0) {
710
+ const logLine = this.stderrLogLineBuf.slice(0, nlIdx).trimEnd();
711
+ this.stderrLogLineBuf = this.stderrLogLineBuf.slice(nlIdx + 1);
712
+ if (logLine)
713
+ this.logger.debug({ codexStderr: logLine }, "[codex-stderr]");
714
+ }
715
+ if (this.stderrLogLineBuf.length > 16384) {
716
+ this.stderrLogLineBuf = this.stderrLogLineBuf.slice(-16384);
717
+ }
650
718
  });
651
719
  child.on("error", (err) => {
652
720
  this.logger.error({ err }, "Codex app-server child process error");
@@ -701,6 +769,23 @@ class CodexAppServerClient {
701
769
  getLastResponsesRequestId() {
702
770
  return this.lastResponsesRequestId;
703
771
  }
772
+ // issue #1886 DIAGNOSTIC: liveness signals the session folds into the watchdog
773
+ // force-fail snapshot. `sinceLastStderrMs` is the key one — see the field comment.
774
+ getLivenessSnapshot() {
775
+ // Session-cumulative (the line-count map is never reset per turn); a near-zero
776
+ // value confirms codex sent almost nothing the daemon ever saw.
777
+ let notificationsSeenTotal = 0;
778
+ for (const count of this.notificationLineCounts.values()) {
779
+ notificationsSeenTotal += count;
780
+ }
781
+ return {
782
+ sinceLastStderrMs: this.stderrLastActivityAt !== null ? Date.now() - this.stderrLastActivityAt : null,
783
+ childAlive: !this.child.killed && !this.disposed,
784
+ childPid: this.child.pid,
785
+ lastResponsesRequestId: this.lastResponsesRequestId,
786
+ notificationsSeenTotal,
787
+ };
788
+ }
704
789
  resetLastResponsesRequestId() {
705
790
  this.lastResponsesRequestId = null;
706
791
  }
@@ -826,6 +911,33 @@ class CodexAppServerClient {
826
911
  }
827
912
  if (typeof msg.method === "string") {
828
913
  const notification = msg;
914
+ // DIAGNOSTIC (watchdog mis-kill investigation): log every notification as it
915
+ // arrives off codex's stdout — this is the first daemon-side touch point, so
916
+ // it proves whether codex actually emitted a notification (vs the daemon
917
+ // dropping it later). High-frequency delta methods are counted, not logged
918
+ // per-event, to avoid flooding; lifecycle methods (turn/*, item/started,
919
+ // item/completed, etc.) are logged individually with their turnId so a
920
+ // "5min with zero turn notifications" gap is directly visible in daemon.log.
921
+ const method = notification.method;
922
+ const isHighFreqDelta = method.includes("/delta") || method.endsWith("Delta");
923
+ this.notificationLineCounts.set(method, (this.notificationLineCounts.get(method) ?? 0) + 1);
924
+ if (isHighFreqDelta) {
925
+ // Log a rollup at most once per second per method so deltas stay visible
926
+ // (proving the stream is alive) without one line per token.
927
+ const now = Date.now();
928
+ const lastLogged = this.notificationDeltaLastLoggedAt.get(method) ?? 0;
929
+ if (now - lastLogged >= 1000) {
930
+ this.notificationDeltaLastLoggedAt.set(method, now);
931
+ this.logger.debug({ method, totalSoFar: this.notificationLineCounts.get(method) }, "[notif-rx] codex delta notification (rolled up)");
932
+ }
933
+ }
934
+ else {
935
+ this.logger.debug({
936
+ method,
937
+ ...summarizeJsonRpcParamsForLog(notification.params),
938
+ totalSoFar: this.notificationLineCounts.get(method),
939
+ }, "[notif-rx] codex notification received off stdout");
940
+ }
829
941
  this.notificationHandler?.(notification.method, notification.params);
830
942
  }
831
943
  }
@@ -1457,15 +1569,23 @@ function normalizeCodexGeneratedImageSource(item) {
1457
1569
  nonEmptyString(item.saved_path) ??
1458
1570
  nonEmptyString(item.path) ??
1459
1571
  null;
1460
- if (savedPath) {
1572
+ const result = nonEmptyString(item.result);
1573
+ if (savedPath && !isEphemeralCodexGeneratedImagePath(savedPath)) {
1461
1574
  return savedPath;
1462
1575
  }
1463
- const result = nonEmptyString(item.result);
1464
1576
  if (!result) {
1577
+ if (savedPath) {
1578
+ return savedPath;
1579
+ }
1465
1580
  return null;
1466
1581
  }
1467
1582
  return result.startsWith("data:") ? result : `data:image/png;base64,${result}`;
1468
1583
  }
1584
+ function isEphemeralCodexGeneratedImagePath(savedPath) {
1585
+ const normalizedPath = path.resolve(savedPath);
1586
+ const attachmentsDir = path.resolve(os.tmpdir(), CODEX_EPHEMERAL_IMAGE_ATTACHMENT_DIR);
1587
+ return (normalizedPath === attachmentsDir || normalizedPath.startsWith(`${attachmentsDir}${path.sep}`));
1588
+ }
1469
1589
  function markdownImageSource(source) {
1470
1590
  if (/^(https?:|data:|blob:)/i.test(source)) {
1471
1591
  return source;
@@ -1585,6 +1705,9 @@ function getImageExtension(mimeType) {
1585
1705
  return "bin";
1586
1706
  }
1587
1707
  }
1708
+ // MIME types we re-encode to WebP. Excludes gif (animation is lost) and webp
1709
+ // (avoids a needless second lossy pass); anything not listed is written as-is.
1710
+ const WEBP_CONVERTIBLE = new Set(["image/png", "image/jpeg", "image/bmp", "image/tiff"]);
1588
1711
  function normalizeImageData(mimeType, data) {
1589
1712
  if (data.startsWith("data:")) {
1590
1713
  const match = data.match(/^data:([^;]+);base64,(.*)$/);
@@ -2274,16 +2397,44 @@ function parseReconnectAttemptCounts(message) {
2274
2397
  }
2275
2398
  return { attempt: Number(match[1]), maxAttempts: Number(match[2]) };
2276
2399
  }
2277
- async function writeImageAttachment(mimeType, data) {
2278
- const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
2400
+ async function writeImageAttachment(mimeType, data, logger) {
2401
+ const attachmentsDir = path.join(resolveSeaworkHome(), CODEX_IMAGE_ATTACHMENT_DIR);
2279
2402
  await fs.mkdir(attachmentsDir, { recursive: true });
2280
2403
  const normalized = normalizeImageData(mimeType, data);
2281
- const extension = getImageExtension(normalized.mimeType);
2282
- const filename = `${randomUUID()}.${extension}`;
2404
+ const rawBuffer = Buffer.from(normalized.data, "base64");
2405
+ let outBuffer = rawBuffer;
2406
+ let outMime = normalized.mimeType;
2407
+ if (WEBP_CONVERTIBLE.has(normalized.mimeType)) {
2408
+ try {
2409
+ const sharp = (await import("sharp")).default;
2410
+ // Downscale oversized images (longest edge > 2048) and re-encode to WebP.
2411
+ // #1824: large image-2 outputs accumulate in every turn's request body
2412
+ // (170MB → 502); downscaling is where the bulk of the savings comes from.
2413
+ const webpBuffer = await sharp(rawBuffer)
2414
+ .resize(2048, 2048, { fit: "inside", withoutEnlargement: true })
2415
+ .webp({ quality: 80 })
2416
+ .toBuffer();
2417
+ // Pathological inputs (noise, already-compressed) can grow under WebP;
2418
+ // only adopt the conversion when it actually shrinks the payload.
2419
+ if (webpBuffer.length < rawBuffer.length) {
2420
+ outBuffer = webpBuffer;
2421
+ outMime = "image/webp";
2422
+ }
2423
+ }
2424
+ catch (error) {
2425
+ // Conversion failed (sharp unavailable / decode error): keep the original
2426
+ // buffer + mime so the turn does not fail and the attachment is preserved.
2427
+ logger.warn({ error }, "sharp webp conversion failed, keeping original");
2428
+ }
2429
+ }
2430
+ const filename = `${randomUUID()}.${getImageExtension(outMime)}`;
2283
2431
  const filePath = path.join(attachmentsDir, filename);
2284
- await fs.writeFile(filePath, Buffer.from(normalized.data, "base64"));
2432
+ await fs.writeFile(filePath, outBuffer);
2285
2433
  return filePath;
2286
2434
  }
2435
+ function resolveSeaworkHome() {
2436
+ return process.env.SEAWORK_HOME ?? path.join(os.homedir(), ".seawork");
2437
+ }
2287
2438
  async function readCodexConfiguredDefaults(client, logger) {
2288
2439
  let savedConfigDefaults = {};
2289
2440
  try {
@@ -2328,7 +2479,7 @@ export async function codexAppServerTurnInputFromPrompt(prompt, logger) {
2328
2479
  typeof record.mimeType === "string" &&
2329
2480
  typeof record.data === "string") {
2330
2481
  try {
2331
- const filePath = await writeImageAttachment(record.mimeType, record.data);
2482
+ const filePath = await writeImageAttachment(record.mimeType, record.data, logger);
2332
2483
  output.push({ type: "localImage", path: filePath });
2333
2484
  }
2334
2485
  catch (error) {
@@ -2387,7 +2538,34 @@ function buildCodexAppServerEnv(runtimeSettings, launchEnv) {
2387
2538
  // the id from the error message. Scoped to a single module plus a global
2388
2539
  // `error` level so stderr stays lean. A user-set RUST_LOG always wins.
2389
2540
  if (!readStringMetadata(merged.RUST_LOG)) {
2390
- merged.RUST_LOG = "error,codex_client::default_client=debug";
2541
+ // DIAGNOSTIC (watchdog mis-kill): SEAWORK_CODEX_VERBOSE_LOG escalates codex's
2542
+ // RUST_LOG so the [codex-stderr] mirror captures codex-internal turn/event
2543
+ // lifecycle. Use this when [notif-rx] shows zero notifications during a stall
2544
+ // and the gap must be attributed to codex (core never emitted / app-server
2545
+ // never wrote stdout) vs the daemon. Off by default to keep stderr lean —
2546
+ // these levels are high-volume on a busy turn. Set in the daemon's
2547
+ // environment, then reload the agent so codex respawns with the new RUST_LOG
2548
+ // (no daemon restart needed once the env is present at daemon start).
2549
+ // 1 / "on" -> turn+event lifecycle (codex_core::tasks + app_server)
2550
+ // "trace" -> also SSE frames (codex_api=trace) — very high volume;
2551
+ // SECURITY: codex_api=trace logs the raw SSE payload for every
2552
+ // frame (including model output tokens, tool call contents, and
2553
+ // assistant text). Use only on isolated dev/debug environments —
2554
+ // never in production — as daemon.log will contain user prompt
2555
+ // and model response data. Proves upstream byte arrival when
2556
+ // core appears stalled.
2557
+ const verbose = readStringMetadata(merged.SEAWORK_CODEX_VERBOSE_LOG)?.toLowerCase();
2558
+ if (verbose === "trace") {
2559
+ merged.RUST_LOG =
2560
+ "error,codex_client::default_client=debug,codex_core::tasks=debug,codex_app_server=debug,codex_api=trace,codex_client=debug";
2561
+ }
2562
+ else if (verbose === "1" || verbose === "on" || verbose === "true") {
2563
+ merged.RUST_LOG =
2564
+ "error,codex_client::default_client=debug,codex_core::tasks=debug,codex_app_server=debug";
2565
+ }
2566
+ else {
2567
+ merged.RUST_LOG = "error,codex_client::default_client=debug";
2568
+ }
2391
2569
  }
2392
2570
  return merged;
2393
2571
  }
@@ -4372,6 +4550,29 @@ class CodexAppServerAgentSession {
4372
4550
  }
4373
4551
  return stderrTail;
4374
4552
  }
4553
+ // issue #1886 DIAGNOSTIC: structured liveness dump emitted when the watchdog
4554
+ // judges the active turn stalled. Unconditional (not RUST_LOG-gated) so a
4555
+ // production stall is always attributable: pairs daemon-side silence with
4556
+ // codex-side liveness to pin the gap on the codex→daemon channel / SSE body
4557
+ // stall vs codex itself going silent.
4558
+ logWatchdogLivenessSnapshot() {
4559
+ const liveness = this.client?.getLivenessSnapshot();
4560
+ this.logger.warn({
4561
+ turnId: this.activeForegroundTurnId,
4562
+ codexTurnId: this.expectedCodexTurnId,
4563
+ sinceLastNotificationMs: this.lastTurnNotificationTime !== null
4564
+ ? Date.now() - this.lastTurnNotificationTime
4565
+ : null,
4566
+ sinceLastStderrMs: liveness?.sinceLastStderrMs ?? null,
4567
+ childAlive: liveness?.childAlive ?? null,
4568
+ childPid: liveness?.childPid ?? null,
4569
+ lastResponsesRequestId: liveness?.lastResponsesRequestId ?? null,
4570
+ inFlightToolCalls: this.inFlightToolCalls.size,
4571
+ compactionInFlight: this.compactionInFlight.size,
4572
+ pendingPermissions: this.pendingPermissionHandlers.size,
4573
+ notificationsSeenTotal: liveness?.notificationsSeenTotal ?? null,
4574
+ }, "[watchdog-snapshot] turn judged stalled; liveness at decision time");
4575
+ }
4375
4576
  emitEvent(event) {
4376
4577
  // Note: assistant-message buffers (`pendingAgentMessages`) are NOT cleared
4377
4578
  // here. Streaming now emits multiple `assistant_message` timeline items
@@ -4484,6 +4685,15 @@ class CodexAppServerAgentSession {
4484
4685
  }
4485
4686
  }
4486
4687
  const stallError = "Codex turn stalled (no events received); recovered by watchdog";
4688
+ // issue #1886 DIAGNOSTIC: the moment the watchdog decides the turn is stalled,
4689
+ // dump a structured liveness snapshot unconditionally (no RUST_LOG gating).
4690
+ // This is the line that closes the gap: it pairs daemon-side silence
4691
+ // (sinceLastNotification) with codex-side liveness (sinceLastStderr, childAlive,
4692
+ // lastResponsesRequestId). Fresh stderr + alive child ⇒ the gap is the
4693
+ // codex→daemon channel / a stalled SSE body (the #1886 case the 6min bump
4694
+ // targets); equally-silent stderr ⇒ codex itself wedged. Previously the
4695
+ // force-fail log carried only sinceLastNotification and could tell neither apart.
4696
+ this.logWatchdogLivenessSnapshot();
4487
4697
  const context = [
4488
4698
  `pendingPermissions=${pendingPermissionsForActiveTurn}`,
4489
4699
  `watchdogCycles inflight=${this.turnWatchdogInflightCycles} compaction=${this.turnWatchdogCompactionCycles}`,
@@ -4815,6 +5025,14 @@ class CodexAppServerAgentSession {
4815
5025
  this.lastTurnNotificationTime = Date.now();
4816
5026
  this.armTurnWatchdog();
4817
5027
  }
5028
+ else {
5029
+ // DIAGNOSTIC (watchdog mis-kill): a notification arrived but there is no
5030
+ // active foreground turn to re-arm the watchdog against. If this fires
5031
+ // during a window where the watchdog later force-fails, it means codex's
5032
+ // liveness signal landed but could not renew the timer — pinpointing the
5033
+ // gap as a turn-identity mismatch rather than codex silence.
5034
+ this.logger.debug({ method, kind: parsed.kind }, "[notif-rx] notification with no activeForegroundTurnId — watchdog NOT re-armed");
5035
+ }
4818
5036
  // issue #1427: a real (non-reconnect) notification after a reconnect marker
4819
5037
  // means the stream is flowing again — close the loading marker so the UI
4820
5038
  // stops showing "Reconnecting…".