specrails-desktop 2.30.0 → 2.30.1

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.
@@ -61,7 +61,7 @@ spawnInteractive = isInteractiveJobsEnabled() // SPECRAILS_INTERACTIV
61
61
  | | `'finalize'` | `'auto'` |
62
62
  |---|---|---|
63
63
  | Who gets it | freestyle/Freestyle QueueManager jobs (claude) | every other interactive job + ALL loop ai-steps |
64
- | End of session | explicit human **Finalize** only (SIGTERM → 2s → SIGKILL) | **quiescence**: a turn `result` arrived, nothing queued, no write in flight |
64
+ | End of session | explicit human **Finalize** only (SIGTERM → 2s → SIGKILL) | **quiescence**: a turn `result` arrived, nothing queued, no write in flight — torn down GRACEFULLY: stdin EOF (the CLI exits itself, flushing its session transcript so the next loop step can `--resume`), SIGTERM only after a grace window (`quiescentEofGraceMs`, default 5s) or when stdin is already gone |
65
65
  | Idle between turns | by design (awaiting the human) | only transiently (microtask window) |
66
66
  | Wedge detector | never armed | the queue's zombie-timeout budget, reset on any raw child output; silence for the whole budget → fold in-flight turn, settle `crashed` |
67
67
  | Composer action | **Finalize Job** | **Wrap up now** (QueueManager) / **Settle this step** (loop step) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -119,6 +119,10 @@ class InteractiveJobSession {
119
119
  _onSettle;
120
120
  _settleMode;
121
121
  _zombieTimeoutMs;
122
+ _quiescentEofGraceMs;
123
+ /** Armed by the quiescent graceful teardown: SIGTERM escalation if the child
124
+ * ignores the stdin EOF. Cleared on close/settle/dispose. */
125
+ _eofTimer = null;
122
126
  _nextEventSeq;
123
127
  _persistTurnUsage;
124
128
  _persistTurnActivity;
@@ -194,6 +198,7 @@ class InteractiveJobSession {
194
198
  this._onSettle = deps.onSettle;
195
199
  this._settleMode = deps.settleMode ?? 'finalize';
196
200
  this._zombieTimeoutMs = deps.zombieTimeoutMs ?? 0;
201
+ this._quiescentEofGraceMs = deps.quiescentEofGraceMs ?? 5000;
197
202
  this._nextEventSeq = deps.nextEventSeq ?? null;
198
203
  this._persistTurnUsage = deps.persistTurnUsage ?? null;
199
204
  this._persistTurnActivity = deps.persistTurnActivity ?? null;
@@ -229,6 +234,7 @@ class InteractiveJobSession {
229
234
  child.on('close', (code) => {
230
235
  this._childClosed = true;
231
236
  this._clearKillTimer();
237
+ this._clearEofTimer();
232
238
  this._handleClose(code);
233
239
  });
234
240
  // A buffered write can fail asynchronously (typically EPIPE after the child
@@ -320,6 +326,39 @@ class InteractiveJobSession {
320
326
  this._clearZombieTimer();
321
327
  this._terminateChildTree('finalized');
322
328
  }
329
+ /** Quiescent auto-settle teardown, GRACEFUL: EOF the child's stdin so the
330
+ * stream-json CLI exits by itself — it flushes its session transcript on the
331
+ * way out, keeping the session `--resume`-able by the NEXT loop step. The
332
+ * previous immediate SIGTERM raced that flush: the next step's resume found
333
+ * no conversation, got an instant empty synthetic result, and settled
334
+ * zero-work in 0.0s. SIGTERM remains as escalation after the grace window,
335
+ * and as the direct fallback when stdin is already gone. */
336
+ _finalizeQuiescent() {
337
+ if (this._finalizing || this._settled)
338
+ return;
339
+ this._finalizing = true;
340
+ this._clearZombieTimer();
341
+ const child = this._child;
342
+ const stdin = child?.stdin;
343
+ if (!child || this._childClosed || !stdin || stdin.destroyed || this._stdinFailed) {
344
+ this._terminateChildTree('finalized');
345
+ return;
346
+ }
347
+ try {
348
+ stdin.end();
349
+ }
350
+ catch {
351
+ this._terminateChildTree('finalized');
352
+ return;
353
+ }
354
+ this._eofTimer = setTimeout(() => {
355
+ this._eofTimer = null;
356
+ if (this._childClosed || this._settled || this._disposed)
357
+ return;
358
+ this._terminateChildTree('finalized');
359
+ }, this._quiescentEofGraceMs);
360
+ this._eofTimer.unref?.();
361
+ }
323
362
  /** Programmatic teardown that SETTLES 'crashed' after folding any in-flight
324
363
  * turn (loop step timeout / run cancel). Unlike dispose(), the onSettle
325
364
  * callback fires — an owner AWAITING the settle (the loop engine) is
@@ -342,6 +381,7 @@ class InteractiveJobSession {
342
381
  return;
343
382
  this._disposed = true;
344
383
  this._clearZombieTimer();
384
+ this._clearEofTimer();
345
385
  this._closeReaders();
346
386
  this._terminateChildTree(null);
347
387
  this._child = null;
@@ -603,7 +643,7 @@ class InteractiveJobSession {
603
643
  return;
604
644
  if (this._streaming || this._pending.length > 0)
605
645
  return;
606
- this.finalize();
646
+ this._finalizeQuiescent();
607
647
  });
608
648
  }
609
649
  }
@@ -785,6 +825,7 @@ class InteractiveJobSession {
785
825
  this._streaming = false;
786
826
  this._awaitingResult = false;
787
827
  this._clearKillTimer();
828
+ this._clearEofTimer();
788
829
  this._clearZombieTimer();
789
830
  this._closeReaders();
790
831
  // The child is gone — any prompts still queued (turn died without a `result`,
@@ -888,6 +929,12 @@ class InteractiveJobSession {
888
929
  this._killTimer = null;
889
930
  }
890
931
  }
932
+ _clearEofTimer() {
933
+ if (this._eofTimer !== null) {
934
+ clearTimeout(this._eofTimer);
935
+ this._eofTimer = null;
936
+ }
937
+ }
891
938
  _closeReaders() {
892
939
  try {
893
940
  this._stdoutReader?.close();