@songsid/agend 2.1.8-beta.7 → 2.1.8-beta.9

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.
package/dist/daemon.d.ts CHANGED
@@ -600,6 +600,8 @@ export declare class Daemon extends EventEmitter {
600
600
  private progressBroadcastTimer;
601
601
  private lastProgressBroadcastAt;
602
602
  private errorMonitorTimer;
603
+ private quitWatchTimer;
604
+ private quitWatchToken;
603
605
  private readonly interactivePromptDetector;
604
606
  private readonly blockingProcessDetector;
605
607
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
@@ -918,8 +920,31 @@ export declare class Daemon extends EventEmitter {
918
920
  * the caller decides what happens next.
919
921
  */
920
922
  private logPaneDeath;
921
- /** Send the backend-specific graceful quit command/key sequence. */
923
+ /**
924
+ * Send the backend-specific graceful quit command/key sequence.
925
+ *
926
+ * Observability (#1030): every send is logged with reason + caller, the
927
+ * outcome is logged, and a non-stop quit arms a bounded relaunch watch —
928
+ * sol's silent wedge (quit landed, codex exited to its resume screen, no
929
+ * relaunch, daemon still running) was invisible precisely because none of
930
+ * this existed.
931
+ */
922
932
  private sendQuitSequence;
933
+ /**
934
+ * Codex names its own resume command on its exit screen — the wedge
935
+ * signature. Backend-generic shell, codex-specific match (#1030 ask 4):
936
+ * other backends' panes simply never match.
937
+ */
938
+ private isCodexResumeScreen;
939
+ private clearQuitRelaunchWatch;
940
+ /**
941
+ * After a non-stop quit, warn if the backend exited but nothing relaunched
942
+ * within the bound while this daemon still considers itself active.
943
+ * Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
944
+ * generation) already happened — only the wedged state warns.
945
+ */
946
+ private armQuitRelaunchWatch;
947
+ private checkQuitRelaunch;
923
948
  /**
924
949
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
925
950
  * the same delivery readiness gate that protects Enter from the busy pane.
package/dist/daemon.js CHANGED
@@ -454,6 +454,11 @@ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
454
454
  * verified prompt before asking the native process to exit. Each instance is
455
455
  * bounded so a fleet update can stop all daemons in parallel. */
456
456
  const KIRO_STOP_IDLE_BUDGET_MS = 15_000;
457
+ /**
458
+ * Bound after a non-stop quit before a dead pane with no relaunch is a wedge
459
+ * (#1030). Exceeds the wake budget (30s+) so a slow relaunch never trips it.
460
+ */
461
+ const QUIT_RELAUNCH_WATCH_MS = 90_000;
457
462
  const KIRO_STOP_QUIT_GRACE_MS = 5_000;
458
463
  const KIRO_STOP_SIGTERM_GRACE_MS = 2_000;
459
464
  /**
@@ -1155,6 +1160,8 @@ export class Daemon extends EventEmitter {
1155
1160
  lastProgressBroadcastAt = 0;
1156
1161
  // PTY error pattern monitoring
1157
1162
  errorMonitorTimer = null;
1163
+ quitWatchTimer = null;
1164
+ quitWatchToken = null;
1158
1165
  interactivePromptDetector = new InteractivePromptDetector();
1159
1166
  blockingProcessDetector = new BlockingProcessDetector();
1160
1167
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
@@ -3005,33 +3012,128 @@ export class Daemon extends EventEmitter {
3005
3012
  this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
3006
3013
  }
3007
3014
  }
3008
- /** Send the backend-specific graceful quit command/key sequence. */
3009
- async sendQuitSequence(reason = "unspecified") {
3015
+ /**
3016
+ * Send the backend-specific graceful quit command/key sequence.
3017
+ *
3018
+ * Observability (#1030): every send is logged with reason + caller, the
3019
+ * outcome is logged, and a non-stop quit arms a bounded relaunch watch —
3020
+ * sol's silent wedge (quit landed, codex exited to its resume screen, no
3021
+ * relaunch, daemon still running) was invisible precisely because none of
3022
+ * this existed.
3023
+ */
3024
+ async sendQuitSequence(reason = "unspecified", caller = "unknown", opts) {
3010
3025
  if (!this.tmux || !this.backend)
3011
3026
  return false;
3027
+ const backend = this.backend.binaryName;
3012
3028
  const quitCmd = this.backend.getQuitCommand();
3013
3029
  const quitKey = this.backend.getQuitKey?.();
3030
+ const quitVia = quitCmd ? `quit command ${quitCmd}` : quitKey ? `quit key ${quitKey}` : "no quit sequence";
3031
+ this.logger.info({ backend, quit: quitCmd ?? quitKey ?? null, reason, caller }, `Sending backend quit sequence (${reason} via ${caller})`);
3014
3032
  if (quitCmd || quitKey)
3015
- this.noteStopRequest(quitCmd ? `quit command ${quitCmd}` : `quit key ${quitKey}`, reason);
3033
+ this.noteStopRequest(quitVia, reason);
3034
+ let sent = false;
3035
+ let step = "sendKeys";
3016
3036
  if (quitCmd) {
3017
- if (!await this.tmux.sendKeys(quitCmd))
3018
- return false;
3019
- // Delay before Enter to prevent tmux server races when instances stop in
3020
- // parallel (same pattern as pasteText).
3021
- await new Promise(r => setTimeout(r, 150));
3022
- return this.tmux.sendSpecialKey("Enter");
3037
+ if (await this.tmux.sendKeys(quitCmd)) {
3038
+ // Delay before Enter to prevent tmux server races when instances stop in
3039
+ // parallel (same pattern as pasteText).
3040
+ await new Promise(r => setTimeout(r, 150));
3041
+ step = "enter";
3042
+ sent = await this.tmux.sendSpecialKey("Enter");
3043
+ }
3044
+ }
3045
+ else if (quitKey) {
3046
+ step = "quit key";
3047
+ sent = true;
3048
+ const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3049
+ for (let i = 0; i < presses; i++) {
3050
+ if (!await this.tmux.sendSpecialKey(quitKey)) {
3051
+ sent = false;
3052
+ break;
3053
+ }
3054
+ if (i + 1 < presses)
3055
+ await new Promise(r => setTimeout(r, 250));
3056
+ }
3023
3057
  }
3024
- if (!quitKey)
3058
+ else {
3059
+ this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
3025
3060
  return false;
3026
- const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
3027
- for (let i = 0; i < presses; i++) {
3028
- const sent = await this.tmux.sendSpecialKey(quitKey);
3029
- if (!sent)
3030
- return false;
3031
- if (i + 1 < presses)
3032
- await new Promise(r => setTimeout(r, 250));
3033
3061
  }
3034
- return true;
3062
+ if (sent) {
3063
+ this.logger.info({ backend, quit: quitCmd ?? quitKey, reason, caller }, "Backend quit sequence sent");
3064
+ if (opts?.watchRelaunch)
3065
+ this.armQuitRelaunchWatch(reason, caller, opts.watchMs);
3066
+ }
3067
+ else {
3068
+ this.logger.warn({ backend, quit: quitCmd ?? quitKey, reason, caller, step }, "Backend quit sequence failed to send");
3069
+ }
3070
+ return sent;
3071
+ }
3072
+ /**
3073
+ * Codex names its own resume command on its exit screen — the wedge
3074
+ * signature. Backend-generic shell, codex-specific match (#1030 ask 4):
3075
+ * other backends' panes simply never match.
3076
+ */
3077
+ isCodexResumeScreen(pane) {
3078
+ return /codex\s+resume\b/i.test(pane);
3079
+ }
3080
+ clearQuitRelaunchWatch() {
3081
+ if (this.quitWatchTimer) {
3082
+ clearTimeout(this.quitWatchTimer);
3083
+ this.quitWatchTimer = null;
3084
+ }
3085
+ // Invalidates any already-started checkQuitRelaunch whose pane reads are
3086
+ // still pending: it must bail on return, not warn from stale results.
3087
+ this.quitWatchToken = null;
3088
+ }
3089
+ /**
3090
+ * After a non-stop quit, warn if the backend exited but nothing relaunched
3091
+ * within the bound while this daemon still considers itself active.
3092
+ * Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
3093
+ * generation) already happened — only the wedged state warns.
3094
+ */
3095
+ armQuitRelaunchWatch(reason, caller, delayMs = QUIT_RELAUNCH_WATCH_MS) {
3096
+ this.clearQuitRelaunchWatch();
3097
+ const generation = this.spawnGeneration;
3098
+ const quitAt = Date.now();
3099
+ const token = {};
3100
+ this.quitWatchToken = token;
3101
+ this.quitWatchTimer = setTimeout(() => {
3102
+ this.quitWatchTimer = null;
3103
+ void this.checkQuitRelaunch({ reason, caller, generation, quitAt, token }).catch(() => { });
3104
+ }, delayMs);
3105
+ }
3106
+ async checkQuitRelaunch(opts) {
3107
+ if (this.pauseWakeState !== "active")
3108
+ return;
3109
+ if (opts.generation !== this.spawnGeneration)
3110
+ return;
3111
+ if (!this.tmux)
3112
+ return;
3113
+ // TOCTOU guard: the token is captured at arm time, so a beginSpawn/stop
3114
+ // that lands while the pane reads below are pending invalidates this
3115
+ // check. Re-validate after every await before using the results.
3116
+ const token = opts.token ?? this.quitWatchToken;
3117
+ let pane;
3118
+ let alive = true;
3119
+ try {
3120
+ const status = await this.tmux.getPaneStatus();
3121
+ alive = !!status?.alive;
3122
+ pane = await this.tmux.capturePane().catch(() => undefined);
3123
+ }
3124
+ catch {
3125
+ return;
3126
+ }
3127
+ if (this.pauseWakeState !== "active")
3128
+ return;
3129
+ if (opts.generation !== this.spawnGeneration)
3130
+ return;
3131
+ if (token !== this.quitWatchToken)
3132
+ return;
3133
+ const resumeScreen = !!pane && this.isCodexResumeScreen(pane);
3134
+ if (alive && !resumeScreen)
3135
+ return;
3136
+ this.logger.warn({ backend: this.backend?.binaryName, reason: opts.reason, caller: opts.caller, quitAt: opts.quitAt, paneAlive: alive, resumeScreen }, "codex-exited-without-relaunch: backend quit but no relaunch followed while this daemon is still active");
3035
3137
  }
3036
3138
  /**
3037
3139
  * Kiro drops `/quit` while a turn is running. Stop that turn first, then use
@@ -3055,6 +3157,7 @@ export class Daemon extends EventEmitter {
3055
3157
  }
3056
3158
  async stop() {
3057
3159
  this.logger.info("Stopping daemon instance");
3160
+ this.clearQuitRelaunchWatch();
3058
3161
  this.fenceDeliveryWritesForStop();
3059
3162
  this.turnReplyGuard.reset();
3060
3163
  // Invalidate any bounded pre-Enter wait from the process generation being
@@ -3081,7 +3184,7 @@ export class Daemon extends EventEmitter {
3081
3184
  let killed = false;
3082
3185
  const windowId = this.tmux.getWindowId();
3083
3186
  const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
3084
- const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
3187
+ const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
3085
3188
  const quitGraceMs = this.backend?.binaryName === "kiro-cli"
3086
3189
  ? KIRO_STOP_QUIT_GRACE_MS : 3_000;
3087
3190
  const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
@@ -3250,7 +3353,7 @@ export class Daemon extends EventEmitter {
3250
3353
  const transition = (async () => {
3251
3354
  try {
3252
3355
  this.saveSessionId();
3253
- await this.sendQuitSequence(pauseReason);
3356
+ await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
3254
3357
  let exited = false;
3255
3358
  for (let i = 0; i < 15; i++) {
3256
3359
  await new Promise(r => setTimeout(r, 200));
@@ -7159,6 +7262,8 @@ export class Daemon extends EventEmitter {
7159
7262
  * into exactly the window this exists to close.
7160
7263
  */
7161
7264
  beginSpawn() {
7265
+ // A relaunch settles any outstanding quit watch: the CLI came back.
7266
+ this.clearQuitRelaunchWatch();
7162
7267
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
7163
7268
  // v1 deliberately does not persist obligations across restarts: missing one
7164
7269
  // warning is safer than treating the replacement's startup idle as the old