specrails-desktop 2.30.0 → 2.30.2

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 (30) hide show
  1. package/client/dist/assets/{ActivityFeedPage-AQgpr9qG.js → ActivityFeedPage-9DZOvIcY.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-I7a0usG7.js → AgentBrowserCapture-DqyHbzk7.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-BqOVg15n.js → AgentModeCodePane-BAYyFRmY.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-4SG6FF27.js → AgentModeJobsPane-CTIHMUKN.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-C3FgYlEi.js → AgentsPage-DuyJuaFs.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-BcMNByW5.js → AnalyticsPage-BY7gtCx6.js} +1 -1
  7. package/client/dist/assets/{CodePage-CgdDkXp-.js → CodePage-vyQr40Vl.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-DPMcS88n.js → DesktopAnalyticsPage-CkXUFj3F.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-BC7574Zi.js → DocsDialog-PhdCJNVj.js} +1 -1
  10. package/client/dist/assets/{DocsPage-Dd9yilUg.js → DocsPage-BY-wDthx.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CqC_0VQL.js → InteractiveJobComposer-C9I0A4KR.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-BFNXOhk1.js → JobDetailModal-I_RoQJV4.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-DM-6zYpA.js → JobDetailPage-CU4FEnUC.js} +1 -1
  14. package/client/dist/assets/{JobsPage-1dkjPk1k.js → JobsPage-DcllM4Ip.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-BPeqz6cG.js → LoopBuilderPage-DGm_iuff.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-EdkyYaGN.js → LoopPreviewModal-BC-TeFdD.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-nZfgiaK8.js → LoopsPage-SqglnXlp.js} +1 -1
  18. package/client/dist/assets/{PluginsPage-CwFfFBzW.js → PluginsPage-B89S8o1a.js} +1 -1
  19. package/client/dist/assets/{ProjectSettingsDialog-DHsxLFMT.js → ProjectSettingsDialog-nEs-ENrE.js} +1 -1
  20. package/client/dist/assets/{TemplatePreviewModal-wcI3nGNr.js → TemplatePreviewModal-D4qW-J9B.js} +1 -1
  21. package/client/dist/assets/{TicketDetailModal-7EloKP1C.js → TicketDetailModal-CI47Dq9E.js} +2 -2
  22. package/client/dist/assets/{dist-js-zvt5ap3o.js → dist-js-Dj72W6qo.js} +1 -1
  23. package/client/dist/assets/{dist-js-Bt2zW2YG.js → dist-js-jwoXo0YD.js} +1 -1
  24. package/client/dist/assets/{index-xkzK3E5i.js → index-F9r8mWP_.js} +5 -5
  25. package/client/dist/index.html +2 -2
  26. package/docs/internals/interactive-jobs.md +1 -1
  27. package/package.json +1 -1
  28. package/server/dist/core-update-manager.js +10 -3
  29. package/server/dist/framework-manager.js +22 -8
  30. package/server/dist/interactive-job-session.js +48 -1
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-xkzK3E5i.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-F9r8mWP_.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
@@ -276,7 +276,7 @@
276
276
  <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-qrcBaLW1.js">
277
277
  <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-ByKli7Q_.js">
278
278
  <link rel="modulepreload" crossorigin href="/assets/spending-DMnCXy84.js">
279
- <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-7EloKP1C.js">
279
+ <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-CI47Dq9E.js">
280
280
  <link rel="stylesheet" crossorigin href="/assets/index-Dh9i4CnN.css">
281
281
  </head>
282
282
  <body>
@@ -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.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -136,9 +136,16 @@ class CoreUpdateManager {
136
136
  if (mat.errors.length > 0) {
137
137
  throw new Error(`framework materialize failed: ${mat.errors.map((e) => `${e.provider}: ${e.message}`).join('; ')}`);
138
138
  }
139
- const swapped = fm.swapCurrent(installed);
140
- if (!swapped) {
141
- throw new Error('framework swap-current failed');
139
+ // A materialize that reports no errors but ALSO no materialized providers
140
+ // wrote nothing — swapping `current` at a nonexistent version dir would
141
+ // fail downstream with a misleading "swap-current failed". Surface the
142
+ // real condition instead (mirrors FrameworkManager.versionCheck's guard).
143
+ if (mat.providers.length === 0) {
144
+ throw new Error(`framework materialize completed without materializing any provider (requested: ${providers.join(', ') || 'none'})`);
145
+ }
146
+ const swapped = fm.swapCurrentDetailed(installed);
147
+ if (!swapped.ok) {
148
+ throw new Error(`framework swap-current failed${swapped.detail ? `: ${swapped.detail}` : ''}`);
142
149
  }
143
150
  this.latestVersion = installed;
144
151
  this.lastCheckedAt = Date.now();
@@ -204,16 +204,30 @@ class FrameworkManager {
204
204
  * `swap-current` only moves the version pointer (provider-invariant).
205
205
  */
206
206
  swapCurrent(version, _provider = 'claude') {
207
+ return this.swapCurrentDetailed(version).ok;
208
+ }
209
+ /**
210
+ * Like `swapCurrent`, but on failure carries WHY it failed (`detail`) — the
211
+ * subprocess stderr/exit or the missing-CLI/verify-mismatch case — so callers
212
+ * (the core update channel) can surface an actionable error instead of the
213
+ * bare "swap-current failed".
214
+ */
215
+ swapCurrentDetailed(version) {
207
216
  const cli = this.resolveCli();
208
217
  if (!cli)
209
- return false;
218
+ return { ok: false, detail: 'no usable core CLI to run swap-current with' };
210
219
  if (readCurrentFrameworkVersion(this.home) === version)
211
- return true;
220
+ return { ok: true, detail: null };
212
221
  const fwDir = frameworkRoot(this.home);
213
222
  const res = (0, child_process_1.spawnSync)(nodeInterpreter(), [cli, 'swap-current', '--framework-dir', fwDir, '--version', version], { env: this.childEnv(), encoding: 'utf-8', timeout: 120_000 });
214
- if (res.error || (res.status ?? 1) !== 0)
215
- return false;
216
- return readCurrentFrameworkVersion(this.home) === version;
223
+ if (res.error || (res.status ?? 1) !== 0) {
224
+ const detail = (res.error?.message ?? `${res.stderr || res.stdout || ''}`.trim()) || `exit ${res.status ?? 'unknown'}`;
225
+ return { ok: false, detail };
226
+ }
227
+ if (readCurrentFrameworkVersion(this.home) !== version) {
228
+ return { ok: false, detail: `swap-current exited 0 but current still resolves to ${readCurrentFrameworkVersion(this.home) ?? 'nothing'}` };
229
+ }
230
+ return { ok: true, detail: null };
217
231
  }
218
232
  /**
219
233
  * Compare the bundled core version with `framework/current`. When they differ
@@ -266,8 +280,8 @@ class FrameworkManager {
266
280
  // No errors yet nothing materialized (empty/duplicate-only provider list).
267
281
  return { swapped: false, version: current };
268
282
  }
269
- const ok = this.swapCurrent(bundled);
270
- if (ok) {
283
+ const swap = this.swapCurrentDetailed(bundled);
284
+ if (swap.ok) {
271
285
  try {
272
286
  this.broadcast?.({ type: 'framework.updated', version: bundled });
273
287
  }
@@ -281,7 +295,7 @@ class FrameworkManager {
281
295
  this.broadcast?.({
282
296
  type: 'framework.update_failed',
283
297
  version: bundled,
284
- errors: [{ provider: providers[0] ?? 'claude', message: 'swap-current failed' }],
298
+ errors: [{ provider: providers[0] ?? 'claude', message: `swap-current failed${swap.detail ? `: ${swap.detail}` : ''}` }],
285
299
  });
286
300
  }
287
301
  catch {
@@ -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();