@quantiya/codevibe-antigravity-plugin 2.0.50 → 2.0.52

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 (2) hide show
  1. package/dist/server.js +330 -49
  2. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -290,9 +290,70 @@ var fs2 = __toESM(require("fs"));
290
290
  var os2 = __toESM(require("os"));
291
291
  var path2 = __toESM(require("path"));
292
292
  var import_chokidar = require("chokidar");
293
+
294
+ // src/shutdown-bounds.ts
295
+ var TEARDOWN_STEP_TIMEOUT_MS = 3e3;
296
+ var WATCHER_CLOSE_TIMEOUT_MS = 2e3;
297
+ var SHUTDOWN_WATCHDOG_MS = 15e3;
298
+ async function boundedStep(work, ms, onTimeout) {
299
+ let timer;
300
+ try {
301
+ const started = (() => {
302
+ try {
303
+ return Promise.resolve(work());
304
+ } catch (err) {
305
+ return Promise.reject(err);
306
+ }
307
+ })();
308
+ const timeout = new Promise((resolve3) => {
309
+ timer = setTimeout(() => resolve3("timeout"), ms);
310
+ if (timer.unref) timer.unref();
311
+ });
312
+ const outcome = await Promise.race([
313
+ started.then(
314
+ () => "settled",
315
+ () => "settled"
316
+ ),
317
+ timeout
318
+ ]);
319
+ if (outcome === "timeout") {
320
+ void started.catch(() => void 0);
321
+ try {
322
+ onTimeout?.();
323
+ } catch {
324
+ }
325
+ }
326
+ return outcome;
327
+ } finally {
328
+ if (timer) clearTimeout(timer);
329
+ }
330
+ }
331
+ function armShutdownWatchdog(exit, onFire, ms = SHUTDOWN_WATCHDOG_MS, durableObligationSettled = () => true) {
332
+ const timer = setTimeout(() => {
333
+ try {
334
+ onFire();
335
+ } catch {
336
+ }
337
+ let settled = false;
338
+ try {
339
+ settled = durableObligationSettled();
340
+ } catch {
341
+ settled = false;
342
+ }
343
+ exit(settled ? 0 : 1);
344
+ }, ms);
345
+ return () => clearTimeout(timer);
346
+ }
347
+
348
+ // src/transcript-tailer.ts
293
349
  var OFFSET_FLUSH_INTERVAL_MS = 3e4;
350
+ var ERROR_LOG_MIN_INTERVAL_MS = 1e4;
294
351
  var LRU_CAP = 50;
295
352
  var TRANSCRIPT_FILE_REGEX = /\/brain\/([0-9a-fA-F-]{36})\/\.system_generated\/logs\/transcript\.jsonl$/;
353
+ var CONVERSATION_DIR_REGEX = /^[0-9a-fA-F-]{36}$/;
354
+ var SYSTEM_GENERATED_DIR = ".system_generated";
355
+ var LOGS_DIR = "logs";
356
+ var TRANSCRIPT_BASENAME = "transcript.jsonl";
296
357
  function defaultPaths() {
297
358
  const home = os2.homedir();
298
359
  return {
@@ -306,6 +367,11 @@ var TranscriptTailer = class extends import_events.EventEmitter {
306
367
  this.watcher = null;
307
368
  this.isWatching = false;
308
369
  this.isShuttingDown = false;
370
+ /** Error-log rate limiting (incident 2026-09-22 — a wedged watcher wrote
371
+ * 356,298 identical EMFILE errors / 96 MB before the daemon was killed). */
372
+ this.lastErrorSignature = null;
373
+ this.lastErrorLoggedAtMs = 0;
374
+ this.suppressedErrorCount = 0;
309
375
  /** Per-file offset state (in-memory mirror of the persisted store). */
310
376
  this.offsets = /* @__PURE__ */ new Map();
311
377
  /** Conversation UUIDs we're actively tailing. v1.0 pins to single-active
@@ -366,25 +432,76 @@ var TranscriptTailer = class extends import_events.EventEmitter {
366
432
  // partial-line carryover handles partial writes
367
433
  usePolling: this.usePolling,
368
434
  interval: this.usePolling ? 50 : void 0,
369
- ignored: (filePath) => {
370
- const isDir = (() => {
371
- try {
372
- return fs2.statSync(filePath).isDirectory();
373
- } catch {
374
- return false;
375
- }
376
- })();
377
- if (isDir) return false;
378
- return !TRANSCRIPT_FILE_REGEX.test(filePath);
379
- }
435
+ // R24 — admit ONLY the exact path shape that can lead to a transcript.
436
+ //
437
+ // THE DEFECT THIS REPLACES: the previous predicate returned `false` for
438
+ // EVERY directory ("allow directories so chokidar can descend into
439
+ // them"), so chokidar descended into the whole brain. Measured on a real
440
+ // machine 2026-09-22: **40,345 directories across 234 conversations**
441
+ // (7.8 GB), on chokidar's kqueue backend which needs roughly one file
442
+ // descriptor per watched directory. The watcher exhausted its descriptors
443
+ // and threw EMFILE continuously — 77,359 errors in a single 28-minute
444
+ // daemon start, 89% of the whole log file across the day.
445
+ //
446
+ // That was not merely wasteful, it was a CORRECTNESS failure, and the
447
+ // exact shape matters because it is worse than a slow start:
448
+ //
449
+ // 1. The initial scan storms for tens of minutes.
450
+ // 2. When it finally completes, `ready` fires and
451
+ // backfillActiveTranscripts() does a ONE-SHOT catch-up read. That
452
+ // uses its own readdirSync, NOT the watcher, so it succeeds — mobile
453
+ // shows a sudden burst of backlog.
454
+ // 3. But the per-file WATCH was never established, because the
455
+ // descriptors were spent on old conversations during the scan. So no
456
+ // `change` event ever arrives again and the tailer STALLS PERMANENTLY
457
+ // at whatever offset the backfill reached.
458
+ //
459
+ // Measured live on the owner's machine while this fix was being written,
460
+ // and RETAINED — every figure here comes from that capture, not from
461
+ // recollection:
462
+ // .hard-gate/r24-watch-scope/evidence/prefix-descriptor-accounting.log
463
+ // The daemon held file descriptors for 231 OLD conversations and **zero**
464
+ // for the active one, while its offset sat frozen at 987,484 bytes
465
+ // against a transcript of 1,656,028 on disk — 668 KB behind and not
466
+ // advancing, with the EMFILE rate back to 0/sec and the daemon otherwise
467
+ // idle at 0.3% CPU. The gap WIDENED between two samplings twenty minutes
468
+ // apart (231 KB, then 668 KB), which is what distinguishes a permanent
469
+ // stall from slow catch-up.
470
+ //
471
+ // User-visible result: a burst of backlog, then silence. Interactive
472
+ // prompts degrade to "Prompt candidate fired but no active conversation
473
+ // known {observedConvCount:0}", which renders on mobile as "a prompt is
474
+ // waiting in your desktop terminal" with NO selectable options — the user
475
+ // can see that a prompt exists but cannot answer it, which is the entire
476
+ // purpose of the mobile companion.
477
+ //
478
+ // NOTE this description was corrected twice. It was first written as
479
+ // "permanent", then weakened to "a bounded dead window" after observing a
480
+ // recovery, then corrected again once the descriptor accounting above was
481
+ // actually measured. The recovery was the one-shot backfill of step 2, not
482
+ // the watcher healing. Stage 1 NB-4 predicted this sub-case before it was
483
+ // measured.
484
+ //
485
+ // THE FIX: the transcript lives at exactly one shape, four levels deep:
486
+ // brain/<uuid>/.system_generated/logs/transcript.jsonl
487
+ // so decide purely LEXICALLY on that shape and refuse everything else.
488
+ // Same machine, after: **699** directories (1 root + 234 conversations +
489
+ // 232 .system_generated + 232 logs) — a 57x reduction that also excludes
490
+ // the actual bulk: scratch/ (230 dirs), .user_uploaded/ (232) and the
491
+ // thousands of .system_generated/tasks/ dirs, none of which can ever hold
492
+ // a transcript.
493
+ //
494
+ // Deciding lexically also removes the per-path `fs.statSync` the old
495
+ // predicate ran during traversal, which was its own CPU cost on a tree
496
+ // this size.
497
+ ignored: (filePath) => this.isOutsideTranscriptPath(filePath)
380
498
  });
381
499
  this.watcher.on("add", (filePath) => this.onFileAdded(filePath));
382
500
  this.watcher.on("addDir", (dirPath) => this.onDirAdded(dirPath));
383
501
  this.watcher.on("change", (filePath) => this.onFileChanged(filePath));
384
502
  this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
385
503
  this.watcher.on("error", (error) => {
386
- logger.error("Chokidar error", { error });
387
- this.emitError(error);
504
+ this.emitError(error, "Chokidar error");
388
505
  });
389
506
  this.watcher.on("ready", () => {
390
507
  logger.info("Transcript tailer ready (chokidar initial scan complete)");
@@ -404,15 +521,25 @@ var TranscriptTailer = class extends import_events.EventEmitter {
404
521
  * covers the residual windows — the doStop teardown detaches listeners
405
522
  * BEFORE the tailer stops, and any embedder that never attached one.
406
523
  */
407
- emitError(error) {
524
+ emitError(error, chokidarLabel) {
408
525
  const err = error instanceof Error ? error : new Error(String(error));
409
- if (this.listenerCount("error") > 0) {
410
- this.emit("error", err);
526
+ const signature = String(err);
527
+ const now = Date.now();
528
+ const suppressed = signature === this.lastErrorSignature && now - this.lastErrorLoggedAtMs < ERROR_LOG_MIN_INTERVAL_MS;
529
+ const hasListener = this.listenerCount("error") > 0;
530
+ if (suppressed) {
531
+ this.suppressedErrorCount++;
411
532
  } else {
412
- logger.error("Transcript tailer error (no listener attached \u2014 logged, non-fatal)", {
413
- error: String(err)
533
+ logger.error(chokidarLabel ?? "Transcript tailer error (non-fatal)", {
534
+ error: signature,
535
+ ...hasListener ? {} : { noListenerAttached: true },
536
+ ...this.suppressedErrorCount > 0 ? { suppressedRepeats: this.suppressedErrorCount } : {}
414
537
  });
538
+ this.lastErrorSignature = signature;
539
+ this.lastErrorLoggedAtMs = now;
540
+ this.suppressedErrorCount = 0;
415
541
  }
542
+ if (hasListener) this.emit("error", err);
416
543
  }
417
544
  /**
418
545
  * Stop watching. Flushes offsets one last time before closing chokidar.
@@ -432,10 +559,16 @@ var TranscriptTailer = class extends import_events.EventEmitter {
432
559
  logger.warn("Final offset flush failed", { error });
433
560
  }
434
561
  if (this.watcher) {
435
- try {
436
- await this.watcher.close();
437
- } catch (error) {
438
- logger.warn("Error closing chokidar watcher", { error });
562
+ const watcher = this.watcher;
563
+ const outcome = await boundedStep(
564
+ () => watcher.close(),
565
+ WATCHER_CLOSE_TIMEOUT_MS,
566
+ () => logger.warn("chokidar watcher.close() timed out \u2014 abandoning it", {
567
+ timeoutMs: WATCHER_CLOSE_TIMEOUT_MS
568
+ })
569
+ );
570
+ if (outcome === "timeout") {
571
+ logger.warn("Transcript tailer watcher abandoned; relying on shutdown watchdog");
439
572
  }
440
573
  this.watcher = null;
441
574
  }
@@ -443,7 +576,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
443
576
  this.pendingReads.clear();
444
577
  this.isWatching = false;
445
578
  this.isShuttingDown = false;
446
- logger.info("Transcript tailer stopped");
579
+ logger.info(
580
+ "Transcript tailer stopped",
581
+ this.suppressedErrorCount > 0 ? { suppressedErrorRepeats: this.suppressedErrorCount } : void 0
582
+ );
583
+ this.lastErrorSignature = null;
584
+ this.suppressedErrorCount = 0;
447
585
  }
448
586
  /**
449
587
  * Best-effort sync flush. Safe to call from a process EXIT trap.
@@ -486,6 +624,48 @@ var TranscriptTailer = class extends import_events.EventEmitter {
486
624
  this.emit("transcript-add", filePath, conversationId);
487
625
  void this.scheduleReadNewLines(filePath, conversationId);
488
626
  }
627
+ /**
628
+ * R24 traversal filter: true = chokidar must NOT descend into / watch this.
629
+ *
630
+ * Purely lexical — no filesystem call — so it is cheap enough to run on every
631
+ * traversed path and cannot be fooled by a stat race. The only paths admitted
632
+ * are the ones that can lead to a transcript:
633
+ *
634
+ * <brainRoot> depth 0
635
+ * <brainRoot>/<uuid> depth 1
636
+ * <brainRoot>/<uuid>/.system_generated depth 2
637
+ * <brainRoot>/<uuid>/.system_generated/logs depth 3
638
+ * <brainRoot>/<uuid>/.system_generated/logs/transcript.jsonl depth 4
639
+ *
640
+ * Anything else — scratch/, evidence/, .user_uploaded/, the task logs, and
641
+ * every file that is not the transcript — is refused, which stops chokidar
642
+ * descending into it at all.
643
+ *
644
+ * NOTE the depth-1 UUID check is what keeps this bounded as the brain grows:
645
+ * a non-conversation entry at the top level can never be a conversation, so
646
+ * it is never traversed. `depth: 4` on the watcher remains as a second,
647
+ * independent bound.
648
+ */
649
+ isOutsideTranscriptPath(filePath) {
650
+ const rel = path2.relative(this.brainRoot, path2.resolve(filePath));
651
+ if (rel === "") return false;
652
+ if (rel === ".." || rel.startsWith(".." + path2.sep)) return true;
653
+ const seg = rel.split(path2.sep);
654
+ if (!CONVERSATION_DIR_REGEX.test(seg[0])) return true;
655
+ switch (seg.length) {
656
+ case 1:
657
+ return false;
658
+ // a well-formed conversation directory
659
+ case 2:
660
+ return seg[1] !== SYSTEM_GENERATED_DIR;
661
+ case 3:
662
+ return seg[2] !== LOGS_DIR;
663
+ case 4:
664
+ return seg[3] !== TRANSCRIPT_BASENAME;
665
+ default:
666
+ return true;
667
+ }
668
+ }
489
669
  onDirAdded(dirPath) {
490
670
  if (this.isShuttingDown) return;
491
671
  const base = path2.basename(dirPath);
@@ -3703,6 +3883,17 @@ var McpServer = class _McpServer {
3703
3883
  * stop() — preserving the monotonic invariant lets stale callbacks
3704
3884
  * reliably detect they're stale. (Codex Stage 2 R1 v5 MED 2026-05-20.) */
3705
3885
  this.lifecycleGen = 0;
3886
+ /** Tracks the durable settlement obligation for a launch row. Set when a
3887
+ * create is ATTEMPTED (before the await — the marker is pre-set), cleared on
3888
+ * a confirmed retirement AND when the create threw. Read by the shutdown
3889
+ * watchdog to choose its exit code. (R2-F1 / R3-F1 / R3-F2 — see
3890
+ * isDurableObligationSettled() for the full predicate and its accepted
3891
+ * residual.) */
3892
+ this.durableObligationOpen = false;
3893
+ /** Teardown steps that exceeded their bound during the current stop().
3894
+ * Surfaced in the shutdown summary so a wedge is visible in the log even
3895
+ * though it no longer blocks exit. (Incident 2026-09-22.) */
3896
+ this.teardownStepsTimedOut = [];
3706
3897
  /** In-flight stop() promise so a re-entrant call (e.g. double SIGINT)
3707
3898
  * awaits the same shutdown instead of bypassing it and calling
3708
3899
  * process.exit before the original stop's cleanup completes.
@@ -3852,8 +4043,7 @@ var McpServer = class _McpServer {
3852
4043
  if (!this.started) {
3853
4044
  return { httpPort: 0 };
3854
4045
  }
3855
- this.addListener(this.transcriptTailer, "error", (err) => {
3856
- logger.error("transcript tailer error (non-fatal)", { error: String(err) });
4046
+ this.addListener(this.transcriptTailer, "error", () => {
3857
4047
  });
3858
4048
  this.addListener(this.transcriptTailer, "event", (emit) => {
3859
4049
  const task = this.handleTranscriptEmit(emit).catch((err) => {
@@ -3915,6 +4105,7 @@ var McpServer = class _McpServer {
3915
4105
  }
3916
4106
  async doStop() {
3917
4107
  this.started = false;
4108
+ this.teardownStepsTimedOut = [];
3918
4109
  this.stopTmuxLifecycleMonitor();
3919
4110
  const launchSettlement = this.launchSessionPromise?.catch(() => void 0) ?? null;
3920
4111
  let mustAwaitLaunchSettlement = false;
@@ -3935,6 +4126,7 @@ var McpServer = class _McpServer {
3935
4126
  const pendingId = this.pendingLaunchSessionId;
3936
4127
  this.stopRequestedDuringCreate = true;
3937
4128
  const deactivated = await this.deactivateLaunchRow(pendingId);
4129
+ if (deactivated) this.durableObligationOpen = false;
3938
4130
  if (deactivated) {
3939
4131
  if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3940
4132
  } else {
@@ -3971,6 +4163,7 @@ var McpServer = class _McpServer {
3971
4163
  logger.warn("[tool-activity] Agy shutdown drain failed", { error: String(err) });
3972
4164
  }
3973
4165
  const retired = await this.deactivateLaunchRow(this.session.sessionId);
4166
+ if (retired) this.durableObligationOpen = false;
3974
4167
  if (!retired) {
3975
4168
  hostedRetirementError = new Error(
3976
4169
  `Failed to retire hosted Antigravity session ${this.session.sessionId}`
@@ -3984,26 +4177,10 @@ var McpServer = class _McpServer {
3984
4177
  }
3985
4178
  }
3986
4179
  this.unregisterSignalHandlers();
3987
- try {
3988
- await this.transcriptTailer.stop();
3989
- } catch (err) {
3990
- logger.warn("transcriptTailer.stop failed", { error: String(err) });
3991
- }
3992
- try {
3993
- await this.paneObserver.stop();
3994
- } catch (err) {
3995
- logger.warn("paneObserver.stop failed", { error: String(err) });
3996
- }
3997
- try {
3998
- await this.approvalDetector.stop();
3999
- } catch (err) {
4000
- logger.warn("approvalDetector.stop failed", { error: String(err) });
4001
- }
4002
- try {
4003
- await this.httpApi.stop();
4004
- } catch (err) {
4005
- logger.warn("httpApi.stop failed", { error: String(err) });
4006
- }
4180
+ await this.boundedTeardownStep("transcriptTailer.stop", () => this.transcriptTailer.stop());
4181
+ await this.boundedTeardownStep("paneObserver.stop", () => this.paneObserver.stop());
4182
+ await this.boundedTeardownStep("approvalDetector.stop", () => this.approvalDetector.stop());
4183
+ await this.boundedTeardownStep("httpApi.stop", () => this.httpApi.stop());
4007
4184
  try {
4008
4185
  this.appSyncClient.cleanupSubscriptions();
4009
4186
  } catch (err) {
@@ -4021,6 +4198,7 @@ var McpServer = class _McpServer {
4021
4198
  if (this.pendingLaunchSessionId) {
4022
4199
  const pendingId = this.pendingLaunchSessionId;
4023
4200
  const retired = await this.deactivateLaunchRow(pendingId);
4201
+ if (retired) this.durableObligationOpen = false;
4024
4202
  if (retired) {
4025
4203
  if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
4026
4204
  } else {
@@ -4117,6 +4295,7 @@ var McpServer = class _McpServer {
4117
4295
  const userId = this.appSyncClient.getCurrentUserId();
4118
4296
  const projectPath = process.cwd();
4119
4297
  this.pendingLaunchSessionId = sessionId;
4298
+ this.durableObligationOpen = true;
4120
4299
  let sessionKey = null;
4121
4300
  let sessionKeyGen = null;
4122
4301
  try {
@@ -4138,7 +4317,10 @@ var McpServer = class _McpServer {
4138
4317
  sessionKey = result.sessionKey ?? null;
4139
4318
  sessionKeyGen = result.sessionKeyGen ?? null;
4140
4319
  } catch (err) {
4141
- if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
4320
+ if (this.pendingLaunchSessionId === sessionId) {
4321
+ this.pendingLaunchSessionId = null;
4322
+ this.durableObligationOpen = false;
4323
+ }
4142
4324
  const msg = String(err);
4143
4325
  if (msg.includes("session-limit-exceeded")) {
4144
4326
  logger.warn("Free-tier session limit reached \u2014 mobile sync disabled for this wrapper", { sessionId });
@@ -4158,7 +4340,10 @@ var McpServer = class _McpServer {
4158
4340
  if (gen !== this.lifecycleGen || !this.started || this.stopRequestedDuringCreate) {
4159
4341
  const deactivated = await this.deactivateLaunchRow(sessionId);
4160
4342
  if (deactivated) {
4161
- if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
4343
+ if (this.pendingLaunchSessionId === sessionId) {
4344
+ this.pendingLaunchSessionId = null;
4345
+ this.durableObligationOpen = false;
4346
+ }
4162
4347
  } else {
4163
4348
  logger.error(
4164
4349
  "createLaunchSession bail: INACTIVE write for the just-created row FAILED \u2014 settlement obligation remains open (marker kept); the row may leak ACTIVE if nothing retries before exit (#638 H4)",
@@ -5503,9 +5688,23 @@ var McpServer = class _McpServer {
5503
5688
  logger.info("Native Antigravity tmux session ended; stopping companion daemon", {
5504
5689
  tmuxTarget
5505
5690
  });
5691
+ const disarmLifecycleWatchdog = armShutdownWatchdog(
5692
+ (code) => process.exit(code),
5693
+ () => logger.error("Shutdown watchdog fired after native session end; exiting anyway", {
5694
+ watchdogMs: SHUTDOWN_WATCHDOG_MS,
5695
+ timedOutSteps: this.teardownStepsTimedOut,
5696
+ durableObligationSettled: this.isDurableObligationSettled()
5697
+ }),
5698
+ SHUTDOWN_WATCHDOG_MS,
5699
+ () => this.isDurableObligationSettled()
5700
+ );
5506
5701
  void this.stop().then(
5507
- () => process.exit(0),
5702
+ () => {
5703
+ disarmLifecycleWatchdog();
5704
+ process.exit(0);
5705
+ },
5508
5706
  (error) => {
5707
+ disarmLifecycleWatchdog();
5509
5708
  logger.error("Failed to stop companion daemon after native session end", {
5510
5709
  error: error instanceof Error ? error.message : String(error)
5511
5710
  });
@@ -5527,9 +5726,26 @@ var McpServer = class _McpServer {
5527
5726
  this.signalsRegistered = true;
5528
5727
  const onSignal = (sig) => {
5529
5728
  logger.info(`${sig} received \u2014 evaluating terminal shutdown`);
5729
+ const disarm = armShutdownWatchdog(
5730
+ (code) => process.exit(code),
5731
+ () => logger.error("Shutdown watchdog fired \u2014 teardown did not complete in time; exiting anyway", {
5732
+ signal: sig,
5733
+ watchdogMs: SHUTDOWN_WATCHDOG_MS,
5734
+ timedOutSteps: this.teardownStepsTimedOut,
5735
+ durableObligationSettled: this.isDurableObligationSettled()
5736
+ }),
5737
+ SHUTDOWN_WATCHDOG_MS,
5738
+ () => this.isDurableObligationSettled()
5739
+ );
5530
5740
  void this.stopForTerminalSignal(sig).then((stopped) => {
5531
- if (stopped) process.exit(0);
5741
+ if (stopped) {
5742
+ disarm();
5743
+ process.exit(0);
5744
+ } else {
5745
+ disarm();
5746
+ }
5532
5747
  }).catch((err) => {
5748
+ disarm();
5533
5749
  logger.error("shutdown error", { error: String(err) });
5534
5750
  process.exit(1);
5535
5751
  });
@@ -5566,6 +5782,71 @@ var McpServer = class _McpServer {
5566
5782
  await this.stop();
5567
5783
  return true;
5568
5784
  }
5785
+ /**
5786
+ * Has shutdown's DURABLE obligation landed? (Stage 1 r1 finding F3.)
5787
+ *
5788
+ * The only obligation that outliving this process can strand is the backend
5789
+ * session row being marked INACTIVE.
5790
+ *
5791
+ * R2-F1: an earlier version read `pendingLaunchSessionId === null &&
5792
+ * session === null`. That was lifecycle-WIRING state used as a proxy for the
5793
+ * durable OUTCOME, and it was wrong in BOTH directions:
5794
+ * (a) `this.session = null` is cleared UNCONDITIONALLY at the end of
5795
+ * Phase 3, including after a retirement that FAILED and set
5796
+ * `hostedRetirementError` — so it reported success on a row known to
5797
+ * be stranded ACTIVE, which is the exact laundering F3 asked to close;
5798
+ * (b) `session` is only cleared AFTER Phase 3, so while the watchdog was
5799
+ * outlasting a wedged Phase-3 step it still read non-null even though
5800
+ * the INACTIVE write had already succeeded — reporting failure for the
5801
+ * wedged-local-handle case that F3 explicitly wanted to keep at exit 0.
5802
+ *
5803
+ * `durableObligationOpen` tracks the outcome directly instead:
5804
+ * - SET when a create is ATTEMPTED (the marker is pre-set before the
5805
+ * await), so it means "a create was attempted", not "a row exists";
5806
+ * - CLEARED when `deactivateLaunchRow` returns true. The stale-lifecycle
5807
+ * clear is additionally identity-guarded because that branch's
5808
+ * precondition is that a NEWER lifecycle exists. The other confirmed
5809
+ * clears are NOT guarded, deliberately: none of them has "a newer
5810
+ * lifecycle exists" as a precondition, and reaching the hazard would
5811
+ * need start() to overlap an in-flight doStop() (doStop sets
5812
+ * started=false first). The Phase-2 clear additionally retires
5813
+ * this.session.sessionId and has no marker to compare against at all,
5814
+ * so "unguarded" there is a category difference, not an omission;
5815
+ * - CLEARED when the create THREW (R3-F2).
5816
+ *
5817
+ * ACCEPTED RESIDUAL: the last rule also clears for a lost-response create
5818
+ * (the server made the row, the response never arrived), so that case is
5819
+ * reported SETTLED even though a row may exist. This is the irreducible
5820
+ * SIGKILL-class residual the file already accepts for
5821
+ * `pendingLaunchSessionId`; the alternative left the flag permanently true
5822
+ * for every free-tier and unkeyed daemon. The row leaked before this change
5823
+ * too and a non-zero exit would not have retired it, so only a diagnostic
5824
+ * signal is traded away.
5825
+ */
5826
+ isDurableObligationSettled() {
5827
+ return !this.durableObligationOpen;
5828
+ }
5829
+ /**
5830
+ * Run one teardown step with a hard bound. Never throws and never hangs, so
5831
+ * a wedged step cannot prevent the steps after it from running.
5832
+ * (Incident 2026-09-22 — see src/shutdown-bounds.ts.)
5833
+ */
5834
+ async boundedTeardownStep(label, work) {
5835
+ const outcome = await boundedStep(
5836
+ async () => {
5837
+ try {
5838
+ await work();
5839
+ } catch (err) {
5840
+ logger.warn(`${label} failed`, { error: String(err) });
5841
+ }
5842
+ },
5843
+ TEARDOWN_STEP_TIMEOUT_MS,
5844
+ () => logger.warn(`${label} timed out \u2014 continuing teardown without it`, {
5845
+ timeoutMs: TEARDOWN_STEP_TIMEOUT_MS
5846
+ })
5847
+ );
5848
+ if (outcome === "timeout") this.teardownStepsTimedOut.push(label);
5849
+ }
5569
5850
  unregisterSignalHandlers() {
5570
5851
  if (!this.signalsRegistered) return;
5571
5852
  if (this.boundSigintHandler) process.off("SIGINT", this.boundSigintHandler);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.50",
3
+ "version": "2.0.52",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {
@@ -46,7 +46,7 @@
46
46
  "node": ">=22.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "@quantiya/codevibe-core": "^2.0.46",
49
+ "@quantiya/codevibe-core": "^2.0.48",
50
50
  "chokidar": "^5.0.0",
51
51
  "dotenv": "^16.6.1",
52
52
  "express": "^5.1.0",