switchroom 0.14.23 → 0.14.24

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.
@@ -49420,8 +49420,8 @@ var {
49420
49420
  } = import__.default;
49421
49421
 
49422
49422
  // src/build-info.ts
49423
- var VERSION = "0.14.23";
49424
- var COMMIT_SHA = "8ac2987a";
49423
+ var VERSION = "0.14.24";
49424
+ var COMMIT_SHA = "2711d052";
49425
49425
 
49426
49426
  // src/cli/agent.ts
49427
49427
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.23",
3
+ "version": "0.14.24",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49225,6 +49225,7 @@ var DEFAULT_RESCAN_MS = 1000;
49225
49225
  var DEFAULT_STALL_THRESHOLD_MS = 60000;
49226
49226
  var DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300000;
49227
49227
  var DEFAULT_SILENT_STALL_TERMINAL_MS = 300000;
49228
+ var DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS = 15 * 60000;
49228
49229
  var SUBAGENT_RESULT_TEXT_MAX = 3000;
49229
49230
  function parseEnvMs(varName) {
49230
49231
  const raw = process.env[varName];
@@ -49415,6 +49416,8 @@ function startSubagentWatcher(config) {
49415
49416
  const stallThresholdMs = config.stallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_MS") ?? DEFAULT_STALL_THRESHOLD_MS;
49416
49417
  const silentSynthesisStallThresholdMs = config.silentSynthesisStallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS") ?? DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS;
49417
49418
  const silentStallTerminalMs = config.silentStallTerminalMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS") ?? DEFAULT_SILENT_STALL_TERMINAL_MS;
49419
+ const inflightPromoteMaxAgeMs = config.inflightPromoteMaxAgeMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS") ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS;
49420
+ const bootPromoteEnabled = config.bootPromoteEnabled ?? process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== "0";
49418
49421
  const reaperTtlMs = config.reaperTtlMs ?? DEFAULT_REAPER_TTL_MS;
49419
49422
  const reaperIntervalMs = config.reaperIntervalMs ?? DEFAULT_REAPER_INTERVAL_MS;
49420
49423
  const rescanMs = config.rescanMs ?? DEFAULT_RESCAN_MS;
@@ -49497,13 +49500,25 @@ function startSubagentWatcher(config) {
49497
49500
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
49498
49501
  }, fs2, log, db2, parentStateDir, config.onUnstall, undefined, config.onProgress);
49499
49502
  if (isHistorical && entry.state === "running") {
49500
- entry.historical = false;
49501
- log?.(`subagent-watcher: ${agentId} was in-flight at boot \u2014 promoting to live (predates watcher; user still awaiting handback)`);
49502
- if (db2 != null) {
49503
- try {
49504
- backfillJsonlAgentId(db2, filePath, agentId, log);
49505
- } catch (err) {
49506
- log?.(`subagent-watcher: backfill error for ${agentId}: ${err.message}`);
49503
+ let fileAgeMs = Infinity;
49504
+ try {
49505
+ const st = fs2.statSync(filePath);
49506
+ if (typeof st.mtimeMs === "number")
49507
+ fileAgeMs = n - st.mtimeMs;
49508
+ } catch {}
49509
+ if (!bootPromoteEnabled) {
49510
+ log?.(`subagent-watcher: ${agentId} running at boot but promotion disabled (SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0) \u2014 leaving historical`);
49511
+ } else if (fileAgeMs > inflightPromoteMaxAgeMs) {
49512
+ log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) \u2014 leaving historical (dead prior-session worker, not in-flight)`);
49513
+ } else {
49514
+ entry.historical = false;
49515
+ log?.(`subagent-watcher: ${agentId} was in-flight at boot \u2014 promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`);
49516
+ if (db2 != null) {
49517
+ try {
49518
+ backfillJsonlAgentId(db2, filePath, agentId, log);
49519
+ } catch (err) {
49520
+ log?.(`subagent-watcher: backfill error for ${agentId}: ${err.message}`);
49521
+ }
49507
49522
  }
49508
49523
  }
49509
49524
  }
@@ -51442,10 +51457,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51442
51457
  }
51443
51458
 
51444
51459
  // ../src/build-info.ts
51445
- var VERSION = "0.14.23";
51446
- var COMMIT_SHA = "8ac2987a";
51447
- var COMMIT_DATE = "2026-05-31T22:03:26Z";
51448
- var LATEST_PR = 2031;
51460
+ var VERSION = "0.14.24";
51461
+ var COMMIT_SHA = "2711d052";
51462
+ var COMMIT_DATE = "2026-05-31T22:59:44Z";
51463
+ var LATEST_PR = 2033;
51449
51464
  var COMMITS_AHEAD_OF_TAG = 0;
51450
51465
 
51451
51466
  // gateway/boot-version.ts
@@ -208,6 +208,23 @@ export interface SubagentWatcherConfig {
208
208
  * synthesis; tests use a tiny value to exercise the path.
209
209
  */
210
210
  silentStallTerminalMs?: number
211
+ /**
212
+ * Freshness window (ms) for promoting a running-at-boot worker file to
213
+ * live. A file whose last write (mtime) is older than this is treated as
214
+ * a dead prior-session worker and stays historical/suppressed, NOT
215
+ * promoted. Default 15 min (DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS); env
216
+ * override `SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS`. Guards the v0.14.23
217
+ * stale-handback replay regression.
218
+ */
219
+ inflightPromoteMaxAgeMs?: number
220
+ /**
221
+ * Kill-switch for the boot-scan promotion path. When false, a
222
+ * running-at-boot worker is never promoted — the watcher reverts to the
223
+ * pre-v0.14.23 behaviour of leaving every boot-scan file historical
224
+ * (suppressed). Default true; env `SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0`
225
+ * disables it fleet-wide without a code change (emergency lever).
226
+ */
227
+ bootPromoteEnabled?: boolean
211
228
  /**
212
229
  * Reaper TTL (ms): background rows in `status='running'` whose
213
230
  * `last_activity_at` (or `started_at` if liveness never wrote) is older
@@ -382,6 +399,29 @@ const DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300_000
382
399
  */
383
400
  const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
384
401
 
402
+ /**
403
+ * Freshness window for the boot-scan "in-flight at boot → promote to
404
+ * live" path. A worker file still in `running` state at boot is only
405
+ * promoted (un-suppressed) if its last write (file mtime) is within this
406
+ * window of now. The signal cleanly separates the two populations:
407
+ *
408
+ * - A worker genuinely in-flight across a restart / fleet rollout was
409
+ * writing right up until the container was recreated, so its mtime is
410
+ * seconds-to-minutes before the new gateway boots — well inside the
411
+ * window. The user is still awaiting it; promote it.
412
+ * - A worker that died in a PRIOR session without writing a terminal
413
+ * `turn_end` is also `running` in the file, but its mtime is hours-to-
414
+ * weeks old. These accumulate by the dozen-to-hundred in a long-lived
415
+ * agent's subagents dir. Promoting them replays stale handbacks
416
+ * (often `failed`, from old error lines) on every boot — the v0.14.23
417
+ * regression. Leave them historical/suppressed, exactly as before.
418
+ *
419
+ * 15 min is generous for any plausible restart gap (container recreate +
420
+ * image pull) yet far below the staleness of a dead prior-session file.
421
+ * Override with `SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS`.
422
+ */
423
+ const DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS = 15 * 60_000
424
+
385
425
  /**
386
426
  * Cap on the result text retained per sub-agent (`entry.lastResultText`)
387
427
  * and carried to the gateway via `onFinish`. The gateway feeds this into
@@ -810,6 +850,14 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
810
850
  config.silentStallTerminalMs
811
851
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS')
812
852
  ?? DEFAULT_SILENT_STALL_TERMINAL_MS
853
+ const inflightPromoteMaxAgeMs =
854
+ config.inflightPromoteMaxAgeMs
855
+ ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
856
+ ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS
857
+ // Kill-switch: not parseEnvMs (which rejects `0`) — an explicit `=0`
858
+ // here MUST disable promotion (revert to pre-v0.14.23 suppression).
859
+ const bootPromoteEnabled =
860
+ config.bootPromoteEnabled ?? (process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== '0')
813
861
  const reaperTtlMs = config.reaperTtlMs ?? DEFAULT_REAPER_TTL_MS
814
862
  const reaperIntervalMs = config.reaperIntervalMs ?? DEFAULT_REAPER_INTERVAL_MS
815
863
  const rescanMs = config.rescanMs ?? DEFAULT_RESCAN_MS
@@ -961,18 +1009,40 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
961
1009
  // already `done` at boot stays historical and is short-circuited just
962
1010
  // below — it finished before this session.)
963
1011
  if (isHistorical && entry.state === 'running') {
964
- entry.historical = false
965
- log?.(`subagent-watcher: ${agentId} was in-flight at boot — promoting to live (predates watcher; user still awaiting handback)`)
966
- // The prior gateway life's registration normally linked
967
- // jsonl_agent_id already, but re-run the backfill idempotently in
968
- // case that life crashed before the link persisted — the handback's
969
- // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
970
- // would mis-resolve the worker as foreground and drop the handback.
971
- if (db != null) {
972
- try {
973
- backfillJsonlAgentId(db, filePath, agentId, log)
974
- } catch (err) {
975
- log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1012
+ // Freshness gate (v0.14.24): only promote a file whose LAST WRITE is
1013
+ // recent. A genuinely in-flight-across-a-restart worker was writing
1014
+ // until the container was recreated (mtime seconds-to-minutes old); a
1015
+ // dead prior-session worker that never wrote a terminal turn_end is
1016
+ // also `running` but hours-to-weeks stale. Promoting the latter
1017
+ // replayed stale `failed` handbacks on every boot (the v0.14.23
1018
+ // fleet-wide regression). Unreadable mtime treat as stale (suppress
1019
+ // rather than risk re-spamming). The kill-switch reverts to pre-fix
1020
+ // suppression entirely.
1021
+ let fileAgeMs = Infinity
1022
+ try {
1023
+ const st = fs.statSync(filePath)
1024
+ if (typeof st.mtimeMs === 'number') fileAgeMs = n - st.mtimeMs
1025
+ } catch {
1026
+ /* unreadable → Infinity → treated as stale below */
1027
+ }
1028
+ if (!bootPromoteEnabled) {
1029
+ log?.(`subagent-watcher: ${agentId} running at boot but promotion disabled (SWITCHROOM_SUBAGENT_BOOT_PROMOTE=0) — leaving historical`)
1030
+ } else if (fileAgeMs > inflightPromoteMaxAgeMs) {
1031
+ log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical (dead prior-session worker, not in-flight)`)
1032
+ } else {
1033
+ entry.historical = false
1034
+ log?.(`subagent-watcher: ${agentId} was in-flight at boot — promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`)
1035
+ // The prior gateway life's registration normally linked
1036
+ // jsonl_agent_id already, but re-run the backfill idempotently in
1037
+ // case that life crashed before the link persisted — the handback's
1038
+ // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
1039
+ // would mis-resolve the worker as foreground and drop the handback.
1040
+ if (db != null) {
1041
+ try {
1042
+ backfillJsonlAgentId(db, filePath, agentId, log)
1043
+ } catch (err) {
1044
+ log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1045
+ }
976
1046
  }
977
1047
  }
978
1048
  }
@@ -80,6 +80,14 @@ function makeHarness(opts: {
80
80
  stallThresholdMs?: number
81
81
  silentStallTerminalMs?: number
82
82
  rescanMs?: number
83
+ /** How long ago (ms) the boot file was last written, i.e. its mtime is
84
+ * `currentTime - bootFileAgeMs` at registration. Default 0 (fresh, so the
85
+ * freshness gate promotes it). Set large to simulate a dead prior-session
86
+ * worker that must NOT be promoted. */
87
+ bootFileAgeMs?: number
88
+ /** Kill-switch passthrough; default true (promotion enabled). */
89
+ bootPromoteEnabled?: boolean
90
+ inflightPromoteMaxAgeMs?: number
83
91
  }): Harness {
84
92
  const {
85
93
  agentId = 'gap-agent',
@@ -87,6 +95,9 @@ function makeHarness(opts: {
87
95
  stallThresholdMs = 60_000,
88
96
  silentStallTerminalMs = 300_000,
89
97
  rescanMs = 500,
98
+ bootFileAgeMs = 0,
99
+ bootPromoteEnabled = true,
100
+ inflightPromoteMaxAgeMs,
90
101
  } = opts
91
102
 
92
103
  let currentTime = 1000
@@ -104,6 +115,10 @@ function makeHarness(opts: {
104
115
 
105
116
  const fileContents = new Map<string, Buffer>()
106
117
  fileContents.set(jsonlPath, Buffer.from(buildJSONL(...bootLines), 'utf-8'))
118
+ // Per-file mtime (ms). The boot file's last write is `bootFileAgeMs` in the
119
+ // past; appends bump it to currentTime. The freshness gate reads this.
120
+ const fileMtimes = new Map<string, number>()
121
+ fileMtimes.set(jsonlPath, 1000 - bootFileAgeMs)
107
122
 
108
123
  let lastOpenedPath: string | null = null
109
124
  const mockFs = {
@@ -121,7 +136,7 @@ function makeHarness(opts: {
121
136
  if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
122
137
  return []
123
138
  }) as unknown as typeof fs.readdirSync,
124
- statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
139
+ statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: fileMtimes.get(String(p)) ?? currentTime }) as fs.Stats) as typeof fs.statSync,
125
140
  openSync: ((p: fs.PathLike) => {
126
141
  lastOpenedPath = String(p)
127
142
  return 42
@@ -153,6 +168,8 @@ function makeHarness(opts: {
153
168
  silentSynthesisStallThresholdMs: stallThresholdMs,
154
169
  silentStallTerminalMs,
155
170
  rescanMs,
171
+ bootPromoteEnabled,
172
+ ...(inflightPromoteMaxAgeMs != null ? { inflightPromoteMaxAgeMs } : {}),
156
173
  onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
157
174
  onFinish: ({ agentId: id, outcome, resultText }) =>
158
175
  finishCalls.push({ agentId: id, outcome, resultText }),
@@ -186,6 +203,7 @@ function makeHarness(opts: {
186
203
  const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
187
204
  const more = buildJSONL(...lines)
188
205
  fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
206
+ fileMtimes.set(jsonlPath, currentTime)
189
207
  }
190
208
 
191
209
  return { stallTerminalCalls, finishCalls, logs, advance, watcher, fileContents, jsonlPath, append }
@@ -245,6 +263,75 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
245
263
  })
246
264
  })
247
265
 
266
+ describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
267
+ // The v0.14.23 regression: promoting EVERY running-at-boot file replayed
268
+ // weeks-old dead prior-session workers as handbacks (often `failed`, from
269
+ // old error lines) on every boot, spamming the whole fleet. The gate
270
+ // promotes only files whose last write is recent.
271
+
272
+ it('a STALE running-at-boot worker (weeks-old mtime) is NOT promoted — no handback, no stall', () => {
273
+ const h = makeHarness({
274
+ agentId: 'gap1-stale-running',
275
+ bootLines: [subAgentUserMsg('bg task from weeks ago')], // running: no turn_end
276
+ bootFileAgeMs: 21 * 24 * 60 * 60_000, // 21 days old — clearly dead
277
+ silentStallTerminalMs: 120_000,
278
+ })
279
+
280
+ h.advance(600)
281
+ h.advance(600_000) // far past every stall/synthesis window
282
+ expect(h.finishCalls).toHaveLength(0) // pre-fix: a spurious (often failed) handback
283
+ expect(h.stallTerminalCalls).toHaveLength(0)
284
+ expect(h.logs.some((l) => l.includes('stale') && l.includes('leaving historical'))).toBe(true)
285
+ })
286
+
287
+ it('a FRESH running-at-boot worker (recent mtime) IS still promoted and hands back', () => {
288
+ // Preserve the genuine Gap 1 fix: a worker in-flight across a restart
289
+ // (wrote moments before the bounce) must still get promoted + handed back.
290
+ const h = makeHarness({
291
+ agentId: 'gap1-fresh-running',
292
+ bootLines: [subAgentUserMsg('bg task')],
293
+ bootFileAgeMs: 30_000, // 30s old — in-flight across a quick restart
294
+ })
295
+
296
+ h.append(subAgentText('Finished the migration'), subAgentTurnEnd())
297
+ h.advance(600)
298
+
299
+ expect(h.finishCalls).toHaveLength(1)
300
+ expect(h.finishCalls[0].outcome).toBe('completed')
301
+ expect(h.logs.some((l) => l.includes('promoting to live'))).toBe(true)
302
+ })
303
+
304
+ it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {
305
+ const h = makeHarness({
306
+ agentId: 'gap1-killswitch',
307
+ bootLines: [subAgentUserMsg('bg task')],
308
+ bootFileAgeMs: 5_000, // fresh — would normally promote
309
+ bootPromoteEnabled: false,
310
+ silentStallTerminalMs: 120_000,
311
+ })
312
+
313
+ h.advance(600)
314
+ h.advance(600_000)
315
+ expect(h.finishCalls).toHaveLength(0)
316
+ expect(h.logs.some((l) => l.includes('promotion disabled'))).toBe(true)
317
+ })
318
+
319
+ it('a worker just past the freshness window is NOT promoted (boundary)', () => {
320
+ const h = makeHarness({
321
+ agentId: 'gap1-boundary',
322
+ bootLines: [subAgentUserMsg('bg task')],
323
+ inflightPromoteMaxAgeMs: 60_000, // 60s window
324
+ bootFileAgeMs: 90_000, // 90s old → just stale
325
+ silentStallTerminalMs: 120_000,
326
+ })
327
+
328
+ h.advance(600)
329
+ h.advance(600_000)
330
+ expect(h.finishCalls).toHaveLength(0)
331
+ expect(h.logs.some((l) => l.includes('stale'))).toBe(true)
332
+ })
333
+ })
334
+
248
335
  describe('Gap 2 — failure honesty', () => {
249
336
  it('a terminal error line flips the outcome to failed and carries the detail', () => {
250
337
  const h = makeHarness({ agentId: 'gap2-failed', bootLines: [subAgentUserMsg('bg task')] })