@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.
- package/dist/server.js +330 -49
- 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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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 (
|
|
413
|
-
error:
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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(
|
|
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", (
|
|
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
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
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)
|
|
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)
|
|
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
|
-
() =>
|
|
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)
|
|
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.
|
|
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.
|
|
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",
|