switchroom 0.18.11 → 0.18.13
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/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -155,6 +155,25 @@ export interface WorkerEntry {
|
|
|
155
155
|
* every real long-runner always carries a `toolu_…` id.
|
|
156
156
|
*/
|
|
157
157
|
inflightToolUseIds: Set<string>
|
|
158
|
+
/**
|
|
159
|
+
* Wall-clock ms of the most recent EMITTED "terminal synthesis deferred"
|
|
160
|
+
* log line for this entry, or null if the entry is not currently in the
|
|
161
|
+
* deferred state (never entered it, or resumed out of it). Drives the
|
|
162
|
+
* per-worker log rate-limit (#3092): null ⇒ this is a fresh entry into the
|
|
163
|
+
* deferred state and MUST log; non-null ⇒ log only once
|
|
164
|
+
* `deferralLogIntervalMs` has elapsed. Reset to null on the un-stall path
|
|
165
|
+
* so a stall → resume → stall cycle logs its first deferral again.
|
|
166
|
+
*/
|
|
167
|
+
deferralLoggedAt: number | null
|
|
168
|
+
/**
|
|
169
|
+
* Count of deferral ticks whose log line was SUPPRESSED by the rate-limit
|
|
170
|
+
* since this entry entered the deferred state (#3092). Carried into the
|
|
171
|
+
* next emitted line (and into the resume / cap-crossing transition lines)
|
|
172
|
+
* so the suppression is itself visible and the operator can see the true
|
|
173
|
+
* tick volume without reading 1,547 lines. Reset alongside
|
|
174
|
+
* `deferralLoggedAt`.
|
|
175
|
+
*/
|
|
176
|
+
deferralSuppressedTicks: number
|
|
158
177
|
/**
|
|
159
178
|
* True if the underlying JSONL file existed before the watcher started.
|
|
160
179
|
* Historical entries are tracked for late state transitions but are
|
|
@@ -330,6 +349,22 @@ export interface SubagentWatcherConfig {
|
|
|
330
349
|
* `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS`.
|
|
331
350
|
*/
|
|
332
351
|
inflightTerminalCapMs?: number
|
|
352
|
+
/**
|
|
353
|
+
* Rate-limit (ms) on the repeated "terminal synthesis deferred" log line
|
|
354
|
+
* for a single worker (#3092). The deferral decision is re-evaluated on
|
|
355
|
+
* every ~1s rescan tick; without this gate each re-evaluation logged,
|
|
356
|
+
* emitting ~1 line/sec for as long as the worker stayed blocked (1,547
|
|
357
|
+
* lines in 35 min on the live overlord gateway, drowning the concurrent
|
|
358
|
+
* 429 flood-ban lines of #3084).
|
|
359
|
+
*
|
|
360
|
+
* Semantics: the FIRST tick that enters the deferred state always logs;
|
|
361
|
+
* subsequent ticks log only once this many ms have passed since the last
|
|
362
|
+
* emitted line. State CHANGES are never suppressed — the cap-crossing
|
|
363
|
+
* ("proceeding with terminal synthesis") and the resume/un-stall lines
|
|
364
|
+
* fire regardless. Default 60_000 (1 min). Env override
|
|
365
|
+
* `SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS`.
|
|
366
|
+
*/
|
|
367
|
+
deferralLogIntervalMs?: number
|
|
333
368
|
/**
|
|
334
369
|
* Freshness window (ms) for promoting a running-at-boot worker file to
|
|
335
370
|
* live. A file whose last write (mtime) is older than this is treated as
|
|
@@ -600,6 +635,16 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
|
|
|
600
635
|
// reaper TTL, so the watcher — not the reaper — still owns the terminal
|
|
601
636
|
// transition for a worker that died mid-tool.
|
|
602
637
|
const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
|
|
638
|
+
// Minimum wall-clock gap between two "terminal synthesis deferred" log lines
|
|
639
|
+
// for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
|
|
640
|
+
// the deferral is re-evaluated (and, before this gate, re-logged) on every one
|
|
641
|
+
// of them. A single worker legitimately blocked inside a long tool call
|
|
642
|
+
// produced 1,547 near-identical lines in 35 minutes on the overlord gateway,
|
|
643
|
+
// burying the concurrent Telegram 429 flood-ban lines (#3084). The DECISION is
|
|
644
|
+
// re-made every tick — only the LOGGING is rate-limited: first entry into the
|
|
645
|
+
// deferred state logs immediately, then at most once per this interval, and
|
|
646
|
+
// every state CHANGE (resume, cap-crossing) logs unconditionally.
|
|
647
|
+
const DEFAULT_DEFERRAL_LOG_INTERVAL_MS = 60_000
|
|
603
648
|
|
|
604
649
|
/**
|
|
605
650
|
* Tools that legitimately run for minutes with ZERO intervening JSONL
|
|
@@ -1183,6 +1228,18 @@ export function readSubTail(
|
|
|
1183
1228
|
log?.(`subagent-watcher: onUnstall callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1184
1229
|
}
|
|
1185
1230
|
}
|
|
1231
|
+
// STATE CHANGE out of the deferred state (#3092) — never rate-
|
|
1232
|
+
// limited. A worker that was deferring terminal synthesis behind an
|
|
1233
|
+
// in-flight tool call has RESURRECTED; that transition is the
|
|
1234
|
+
// payoff of the deferral and must be visible even though the
|
|
1235
|
+
// intervening per-tick deferral lines were suppressed. Report the
|
|
1236
|
+
// suppressed volume so the quiet window is accounted for, then
|
|
1237
|
+
// re-arm so a subsequent re-stall logs its first deferral again.
|
|
1238
|
+
if (entry.deferralLoggedAt != null) {
|
|
1239
|
+
log?.(`subagent-watcher: in-flight deferral resolved for ${entry.agentId} (worker resumed after ${idleSecBeforeBump}s idle — deferral was correct, not a dead worker; ${entry.deferralSuppressedTicks} deferral tick(s) suppressed since the last deferral line)`)
|
|
1240
|
+
entry.deferralLoggedAt = null
|
|
1241
|
+
entry.deferralSuppressedTicks = 0
|
|
1242
|
+
}
|
|
1186
1243
|
log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
|
|
1187
1244
|
}
|
|
1188
1245
|
if (ev.kind === 'sub_agent_model') {
|
|
@@ -1457,6 +1514,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1457
1514
|
config.inflightTerminalCapMs
|
|
1458
1515
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
|
|
1459
1516
|
?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
|
|
1517
|
+
const deferralLogIntervalMs =
|
|
1518
|
+
config.deferralLogIntervalMs
|
|
1519
|
+
?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
|
|
1520
|
+
?? DEFAULT_DEFERRAL_LOG_INTERVAL_MS
|
|
1460
1521
|
const inflightPromoteMaxAgeMs =
|
|
1461
1522
|
config.inflightPromoteMaxAgeMs
|
|
1462
1523
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
|
|
@@ -1603,6 +1664,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1603
1664
|
stalledAt: null,
|
|
1604
1665
|
completionNotified: false,
|
|
1605
1666
|
stallTerminalSynthesised: false,
|
|
1667
|
+
deferralLoggedAt: null,
|
|
1668
|
+
deferralSuppressedTicks: 0,
|
|
1606
1669
|
lastSummaryLine: '',
|
|
1607
1670
|
lastResultText: '',
|
|
1608
1671
|
lastProgressBucketIdx: null,
|
|
@@ -1716,21 +1779,39 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1716
1779
|
maybySendStateTransition(agentId)
|
|
1717
1780
|
}
|
|
1718
1781
|
|
|
1719
|
-
// Set up FSWatcher
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1782
|
+
// Set up FSWatcher.
|
|
1783
|
+
//
|
|
1784
|
+
// FD-leak fix (H2): only open an inotify watch when it can do real work.
|
|
1785
|
+
// A historical entry that is done-at-boot (already `scheduleTerminalCleanup`d
|
|
1786
|
+
// just above) or a stale `running` entry that will never be promoted (no
|
|
1787
|
+
// `bootPromotionPending`) has NO live transition left to observe —
|
|
1788
|
+
// `checkStalls` skips every historical entry, so such an entry never
|
|
1789
|
+
// reaches a terminal transition and `scheduleTerminalCleanup` never runs
|
|
1790
|
+
// for it. Opening an `fs.watch` for one leaks its inotify FD for the whole
|
|
1791
|
+
// gateway lifetime (the dozens-to-hundreds of dead prior-session
|
|
1792
|
+
// `running` JSONLs a 24/7 agent accumulates). The poll loop still reads
|
|
1793
|
+
// any registered running entry defensively, so dropping the watcher here
|
|
1794
|
+
// costs nothing but the leaked FD. Open the watch only for a live worker
|
|
1795
|
+
// or a historical one still awaiting boot-promotion growth confirmation.
|
|
1796
|
+
const needsWatcher = !entry.historical || entry.bootPromotionPending != null
|
|
1797
|
+
if (needsWatcher) {
|
|
1798
|
+
try {
|
|
1799
|
+
tail.watcher = fs.watch(filePath, () => {
|
|
1800
|
+
if (stopped) return
|
|
1801
|
+
const entry = registry.get(agentId)
|
|
1802
|
+
const t = tails.get(agentId)
|
|
1803
|
+
if (!entry || !t) return
|
|
1804
|
+
checkBootPromotionGrowth(entry, t, nowFn())
|
|
1805
|
+
readSubTail(entry, t, nowFn(), (desc) => {
|
|
1806
|
+
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1807
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
1808
|
+
maybySendStateTransition(agentId)
|
|
1809
|
+
})
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
|
|
1812
|
+
}
|
|
1813
|
+
} else {
|
|
1814
|
+
log?.(`subagent-watcher: ${agentId} historical/terminal at registration — not opening an FSWatcher (no live transition to observe)`)
|
|
1734
1815
|
}
|
|
1735
1816
|
}
|
|
1736
1817
|
|
|
@@ -1792,7 +1873,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1792
1873
|
}
|
|
1793
1874
|
if (n >= pending.deadlineAt) {
|
|
1794
1875
|
entry.bootPromotionPending = undefined
|
|
1795
|
-
|
|
1876
|
+
// FD-leak fix (H2): the boot-promotion window elapsed with no growth,
|
|
1877
|
+
// so this entry is now a permanent historical/orphan that `checkStalls`
|
|
1878
|
+
// skips — it will never reach terminal cleanup. Release the FSWatcher we
|
|
1879
|
+
// opened purely for growth-confirmation now; the poll loop remains the
|
|
1880
|
+
// fallback reader if the file ever resumes (rescan re-registers on a
|
|
1881
|
+
// fresh transition).
|
|
1882
|
+
if (tail.watcher) {
|
|
1883
|
+
try { tail.watcher.close() } catch { /* ignore */ }
|
|
1884
|
+
tail.watcher = null
|
|
1885
|
+
}
|
|
1886
|
+
log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart); released growth-confirmation FSWatcher`)
|
|
1796
1887
|
}
|
|
1797
1888
|
}
|
|
1798
1889
|
|
|
@@ -1987,6 +2078,19 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1987
2078
|
existing.stallNotified = false
|
|
1988
2079
|
existing.stalledAt = null
|
|
1989
2080
|
existing.stallTerminalSynthesised = false
|
|
2081
|
+
// Re-arm the deferral log gate alongside its stall siblings (#3092).
|
|
2082
|
+
// Unreachable with stale state TODAY (both routes out of the deferred
|
|
2083
|
+
// state to `done` — the cap-crossing and the un-stall drain — already
|
|
2084
|
+
// null `deferralLoggedAt`), so this is belt-and-braces, not a live fix.
|
|
2085
|
+
// Reset it here anyway: a resurrected entry must be indistinguishable
|
|
2086
|
+
// from a fresh one, so a genuinely NEW stall always logs its FIRST
|
|
2087
|
+
// deferral line immediately. Leaving these to a reachability argument
|
|
2088
|
+
// makes the gate fragile by construction — a later reorder of the drain,
|
|
2089
|
+
// or a third path to `done`, would silently suppress that first line for
|
|
2090
|
+
// up to `deferralLogIntervalMs`, losing the exact signal this gate
|
|
2091
|
+
// exists to preserve.
|
|
2092
|
+
existing.deferralLoggedAt = null
|
|
2093
|
+
existing.deferralSuppressedTicks = 0
|
|
1990
2094
|
// Re-arm the completion notification INTENTIONALLY (issue #3023). The
|
|
1991
2095
|
// false synthesized finish already fired `onFinish` once, delivering a
|
|
1992
2096
|
// (possibly wrong / incomplete) synthesized handback to the parent.
|
|
@@ -2212,10 +2316,34 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2212
2316
|
if (entry.inflightToolUseIds.size > 0) {
|
|
2213
2317
|
const totalIdleMs = n - entry.lastActivityAt
|
|
2214
2318
|
if (totalIdleMs < inflightTerminalCapMs) {
|
|
2215
|
-
|
|
2319
|
+
// Log-rate gate (#3092). The deferral DECISION above is re-made on
|
|
2320
|
+
// every ~1s rescan tick and is correct; only its LOGGING is gated.
|
|
2321
|
+
// Emit on first entry into the deferred state, then at most once per
|
|
2322
|
+
// `deferralLogIntervalMs`. Everything else is counted, not printed —
|
|
2323
|
+
// the suppressed count rides the next emitted line so no volume is
|
|
2324
|
+
// lost, just the 1/sec repetition (1,547 lines / 35 min on overlord,
|
|
2325
|
+
// burying the concurrent #3084 flood-ban lines).
|
|
2326
|
+
const lastLoggedAt = entry.deferralLoggedAt
|
|
2327
|
+
const firstEntry = lastLoggedAt == null
|
|
2328
|
+
if (firstEntry || n - lastLoggedAt >= deferralLogIntervalMs) {
|
|
2329
|
+
const suppressed = entry.deferralSuppressedTicks
|
|
2330
|
+
const suffix = firstEntry
|
|
2331
|
+
? ` — further deferral ticks for this worker are rate-limited to 1 line / ${Math.floor(deferralLogIntervalMs / 1000)}s until it resumes or crosses the cap`
|
|
2332
|
+
: ` — still deferred (${suppressed} tick(s) suppressed since the last line)`
|
|
2333
|
+
log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)${suffix}`)
|
|
2334
|
+
entry.deferralLoggedAt = n
|
|
2335
|
+
entry.deferralSuppressedTicks = 0
|
|
2336
|
+
} else {
|
|
2337
|
+
entry.deferralSuppressedTicks++
|
|
2338
|
+
}
|
|
2216
2339
|
continue
|
|
2217
2340
|
}
|
|
2218
|
-
|
|
2341
|
+
// STATE CHANGE — never rate-limited. The deferral ends here and a
|
|
2342
|
+
// terminal synthesis fires; this is the transition an operator needs.
|
|
2343
|
+
const suppressedTotal = entry.deferralSuppressedTicks
|
|
2344
|
+
log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis (${suppressedTotal} deferral tick(s) suppressed since the last deferral line)`)
|
|
2345
|
+
entry.deferralLoggedAt = null
|
|
2346
|
+
entry.deferralSuppressedTicks = 0
|
|
2219
2347
|
}
|
|
2220
2348
|
// TODO(#3023/PR #3029): the cap-reached path above is a SECOND source of
|
|
2221
2349
|
// possibly-false terminal synthesis (a worker mid-very-long-tool that is
|
|
@@ -2286,8 +2414,35 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2286
2414
|
* We walk: <agentDir>/.claude/projects/ → each project dir → each session dir
|
|
2287
2415
|
* → subagents/ → agent-*.jsonl
|
|
2288
2416
|
*/
|
|
2417
|
+
/**
|
|
2418
|
+
* FD-leak fix (H1): close and forget any directory FSWatcher whose watched
|
|
2419
|
+
* directory no longer exists on disk. Every new Claude session mints a fresh
|
|
2420
|
+
* `<sessionId>/subagents/` dir (and `workflows/wf_<id>` sub-dirs); when Claude
|
|
2421
|
+
* Code reaps a prior session's directory, the scan loop below simply skips
|
|
2422
|
+
* the vanished path (`existsSync` guard) WITHOUT closing the watcher it had
|
|
2423
|
+
* opened for it — Node only frees the inotify FD on `.close()`, so each dead
|
|
2424
|
+
* session leaked one dir watcher for the whole gateway lifetime. Sweeping on
|
|
2425
|
+
* every rescan releases them deterministically the tick after the dir goes
|
|
2426
|
+
* away, and also covers a slug that transitions to "foreign" (skipped by the
|
|
2427
|
+
* allow-list filter below). Deleting the current key while iterating a Map's
|
|
2428
|
+
* entries() is safe in JS.
|
|
2429
|
+
*/
|
|
2430
|
+
function pruneVanishedDirWatchers(): void {
|
|
2431
|
+
for (const [dirPath, w] of dirWatchers) {
|
|
2432
|
+
if (!fs.existsSync(dirPath)) {
|
|
2433
|
+
try { w.close() } catch { /* ignore */ }
|
|
2434
|
+
dirWatchers.delete(dirPath)
|
|
2435
|
+
log?.(`subagent-watcher: released dir watcher for vanished ${dirPath}`)
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2289
2440
|
function rescanSubagentDirs(): void {
|
|
2290
2441
|
if (stopped) return
|
|
2442
|
+
// Release watchers for directories reaped since the last tick BEFORE the
|
|
2443
|
+
// early-returns below, so a projectsRoot that vanishes entirely still frees
|
|
2444
|
+
// every child dir watcher.
|
|
2445
|
+
pruneVanishedDirWatchers()
|
|
2291
2446
|
const claudeHome = join(agentDir, '.claude')
|
|
2292
2447
|
const projectsRoot = join(claudeHome, 'projects')
|
|
2293
2448
|
if (!fs.existsSync(projectsRoot)) return
|
|
@@ -66,14 +66,27 @@ describe('activity-card durability wiring', () => {
|
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
69
|
+
// #3026: the reaper is now invoked via the mutex-gated orchestrator
|
|
70
|
+
// runBootPinCleanupAndDmSweep() (which awaits it alongside
|
|
71
|
+
// statusPinBootCleanup + queuedCardBootReaper, then runs the DM
|
|
72
|
+
// stale-pin sweep). Invariant unchanged: reachable only post-lock.
|
|
73
|
+
// (1) The orchestrator awaits the reaper.
|
|
74
|
+
const orchestrator = between(
|
|
75
|
+
gatewaySrc,
|
|
76
|
+
'async function runBootPinCleanupAndDmSweep()',
|
|
77
|
+
'dmPinSweepEligible = true',
|
|
78
|
+
)
|
|
79
|
+
expect(orchestrator).toMatch(/await activityCardBootReaper\(\)/)
|
|
80
|
+
// (2) Both orchestrator invocation sites (mutex-won + mutex-fallback)
|
|
81
|
+
// sit AFTER acquireStartupLock.
|
|
71
82
|
const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
|
|
72
|
-
expect(afterLock).toMatch(/void
|
|
73
|
-
//
|
|
74
|
-
// boot hazard the NOTE at
|
|
83
|
+
expect(afterLock).toMatch(/void runBootPinCleanupAndDmSweep\(\)/)
|
|
84
|
+
// (3) Neither the reaper nor the orchestrator is invoked at module import
|
|
85
|
+
// time — the same losing-double-boot hazard the NOTE at
|
|
86
|
+
// statusPinBootCleanup documents.
|
|
75
87
|
const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
|
|
76
88
|
expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
|
|
89
|
+
expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndDmSweep\(\)/m)
|
|
77
90
|
})
|
|
78
91
|
|
|
79
92
|
it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {
|