switchroom 0.17.6 → 0.17.10
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 +38 -4
- package/dist/auth-broker/index.js +302 -203
- package/dist/cli/notion-write-pretool.mjs +35 -2
- package/dist/cli/switchroom.js +1178 -576
- package/dist/host-control/main.js +148 -14
- package/dist/vault/approvals/kernel-server.js +140 -55
- package/dist/vault/broker/server.js +142 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2565 -610
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/gateway.ts +1376 -82
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome-based regression test for the deterministic silent-turn card climb.
|
|
3
|
+
*
|
|
4
|
+
* Phase 4(b) of `reference/rfcs/deterministic-turn-liveness.md`; serves
|
|
5
|
+
* `reference/jobs/know-what-my-agent-is-doing.md` (a busy-but-silent turn must
|
|
6
|
+
* visibly escalate, never freeze).
|
|
7
|
+
*
|
|
8
|
+
* ## What is REAL here, and what is a spy (no overclaim)
|
|
9
|
+
*
|
|
10
|
+
* The gateway is a start-on-import IIFE that cannot be instantiated in-process
|
|
11
|
+
* (the documented constraint behind every `*-wiring` structural test in this
|
|
12
|
+
* suite). So the shipped 0-label tick body was EXTRACTED into
|
|
13
|
+
* `feed-heartbeat-climb.ts` (`runSilentTurnHeartbeatTick`) and the gateway's
|
|
14
|
+
* `feedHeartbeatTick` calls it with its real deps — meaning the function under
|
|
15
|
+
* test IS the production decision logic, byte-for-byte, not a hand-copied
|
|
16
|
+
* mirror. What this test drives:
|
|
17
|
+
*
|
|
18
|
+
* - REAL: `runSilentTurnHeartbeatTick` + `silentTurnClimbRender` — the shipped
|
|
19
|
+
* guard placement (0-label check, open-vs-climb split, stale-guard, null
|
|
20
|
+
* render), the gate SEQUENCE (cardDrainGate → mayDrain → openOrEditCard →
|
|
21
|
+
* drain), and the rendered card bodies.
|
|
22
|
+
* - SPY: the injected deps. The `drain` seam is where the gateway's
|
|
23
|
+
* `drainActivitySummary` → `robustApiCall(bot.api.sendMessage /
|
|
24
|
+
* bot.api.editMessageText)` lives in production; the spy implements the
|
|
25
|
+
* drain's transport contract (send when no card id, edit once set) and
|
|
26
|
+
* records every call with a timestamp. The real `drainActivitySummary` /
|
|
27
|
+
* grammY transport is NOT executed here — the gateway wiring of the real
|
|
28
|
+
* deps (and the real drain call) is pinned structurally by
|
|
29
|
+
* `tests/feed-heartbeat-liveness-open.test.ts`, and end-to-end by the UAT
|
|
30
|
+
* liveness scenarios.
|
|
31
|
+
*
|
|
32
|
+
* ## What it asserts
|
|
33
|
+
*
|
|
34
|
+
* 1. A ~40s silent tool call produces >=4 card EDITS with a CLIMBING elapsed —
|
|
35
|
+
* the card never freezes. (Acceptance criterion of the RFC.)
|
|
36
|
+
* 2. Every climb mutation is an EDIT of the already-open card — never a new
|
|
37
|
+
* send — so no mid-turn mutation can push-notify the device.
|
|
38
|
+
* 3. Each climb edit passes through the full gate sequence in order:
|
|
39
|
+
* setPendingRender → cardDrainGate → mayDrain → openOrEditCard → drain
|
|
40
|
+
* (mis-wiring the guards inside the tick body fails this).
|
|
41
|
+
* 4. `mayDrain === false` (drain in flight) suppresses the edit — the
|
|
42
|
+
* single-flight guard is honoured.
|
|
43
|
+
* 5. The climb yields to model narration: a surfaced tool label
|
|
44
|
+
* (`mirrorLineCount > 0`) returns `false` (labelled branch owns the tick)
|
|
45
|
+
* and emits nothing.
|
|
46
|
+
* 6. The stale-guard: a turn younger than `minStaleMs` produces no edit (same
|
|
47
|
+
* symmetry the labelled branch has).
|
|
48
|
+
*
|
|
49
|
+
* ## Why this FAILS on pre-fix code (its acceptance criterion)
|
|
50
|
+
*
|
|
51
|
+
* Pre-fix, the 0-label branch delegated solely to `openLivenessFeedIfDue`,
|
|
52
|
+
* whose WHEN-gate returns false the moment a card is open — an open 0-label
|
|
53
|
+
* card received ZERO edits during a silent tool. Reverting
|
|
54
|
+
* `runSilentTurnHeartbeatTick` to that behaviour (the open-path delegate with
|
|
55
|
+
* no climb) drives this test's edit count to 0, failing assertion 1 — through
|
|
56
|
+
* the same shipped function the gateway calls. Removing the gateway call site
|
|
57
|
+
* instead is caught by the structural pin in
|
|
58
|
+
* `tests/feed-heartbeat-liveness-open.test.ts`.
|
|
59
|
+
*/
|
|
60
|
+
import { describe, it, expect } from 'vitest'
|
|
61
|
+
import {
|
|
62
|
+
runSilentTurnHeartbeatTick,
|
|
63
|
+
silentTurnClimbRender,
|
|
64
|
+
type SilentTurnTickDeps,
|
|
65
|
+
} from '../feed-heartbeat-climb.js'
|
|
66
|
+
|
|
67
|
+
// The gateway constants that drive the tick cadence (gateway.ts).
|
|
68
|
+
const FEED_HEARTBEAT_TICK_MS = 6_000
|
|
69
|
+
const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
|
|
70
|
+
const FEED_LIVENESS_OPEN_MS = 1_200
|
|
71
|
+
|
|
72
|
+
/** A recorded transport call with the simulated wall-clock time it happened at. */
|
|
73
|
+
interface TransportCall {
|
|
74
|
+
kind: 'sendMessage' | 'editMessageText'
|
|
75
|
+
at: number
|
|
76
|
+
text: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the climbing elapsed (in seconds) out of a rendered card header line
|
|
80
|
+
* (`_6s · 0 tools_`, `_1m05s · 0 tools_`). Returns null if not present. */
|
|
81
|
+
function parseHeaderElapsedSeconds(text: string): number | null {
|
|
82
|
+
const m = text.match(/_((?:(\d+)m)?(\d+)s) · \d+ tools?_/)
|
|
83
|
+
if (m == null) return null
|
|
84
|
+
const minutes = m[2] ? Number(m[2]) : 0
|
|
85
|
+
const seconds = Number(m[3])
|
|
86
|
+
return minutes * 60 + seconds
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Test double for the turn's card surface + injected deps. The `drain` spy
|
|
91
|
+
* implements the drain's transport contract — `sendMessage` (an OPEN, the only
|
|
92
|
+
* mutation that push-notifies) when no card id exists, `editMessageText` (a
|
|
93
|
+
* silent card EDIT) once it does — standing in for `drainActivitySummary` →
|
|
94
|
+
* `robustApiCall(bot.api.*)`. Also records the gate-call ORDER per tick so the
|
|
95
|
+
* shipped sequence is pinned, and simulates the shared OPEN path
|
|
96
|
+
* (`openLivenessFeedIfDue`) opening the placeholder card.
|
|
97
|
+
*/
|
|
98
|
+
class TurnHarness {
|
|
99
|
+
calls: TransportCall[] = []
|
|
100
|
+
order: string[] = []
|
|
101
|
+
activityMessageId: number | null = null
|
|
102
|
+
pendingRender: string | null = null
|
|
103
|
+
mayDrainResult = true
|
|
104
|
+
private nextId = 100
|
|
105
|
+
now = 0
|
|
106
|
+
|
|
107
|
+
/** The real gateway wires these exact seams; each spy records its call. */
|
|
108
|
+
deps(openRender: () => string | null): SilentTurnTickDeps {
|
|
109
|
+
return {
|
|
110
|
+
openLivenessFeedIfDue: () => {
|
|
111
|
+
this.order.push('openLivenessFeedIfDue')
|
|
112
|
+
// Mirror of the shared OPEN path's contract: opens once, past its
|
|
113
|
+
// threshold, via a fresh sendMessage.
|
|
114
|
+
if (this.activityMessageId != null) return
|
|
115
|
+
const rendered = openRender()
|
|
116
|
+
if (rendered == null) return
|
|
117
|
+
this.activityMessageId = this.nextId++
|
|
118
|
+
this.calls.push({ kind: 'sendMessage', at: this.now, text: rendered })
|
|
119
|
+
},
|
|
120
|
+
setPendingRender: (rendered) => {
|
|
121
|
+
this.order.push('setPendingRender')
|
|
122
|
+
this.pendingRender = rendered
|
|
123
|
+
},
|
|
124
|
+
cardDrainGate: (run) => {
|
|
125
|
+
this.order.push('cardDrainGate')
|
|
126
|
+
run()
|
|
127
|
+
},
|
|
128
|
+
mayDrain: () => {
|
|
129
|
+
this.order.push('mayDrain')
|
|
130
|
+
return this.mayDrainResult
|
|
131
|
+
},
|
|
132
|
+
openOrEditCard: (apply) => {
|
|
133
|
+
this.order.push('openOrEditCard')
|
|
134
|
+
apply()
|
|
135
|
+
},
|
|
136
|
+
drain: () => {
|
|
137
|
+
this.order.push('drain')
|
|
138
|
+
// drainActivitySummary's transport contract: OPEN when no card id,
|
|
139
|
+
// EDIT in place once set. Here the card is always open (the tick only
|
|
140
|
+
// reaches drain via the climb), so this is always an edit.
|
|
141
|
+
const text = this.pendingRender ?? ''
|
|
142
|
+
if (this.activityMessageId == null) {
|
|
143
|
+
this.activityMessageId = this.nextId++
|
|
144
|
+
this.calls.push({ kind: 'sendMessage', at: this.now, text })
|
|
145
|
+
} else {
|
|
146
|
+
this.calls.push({ kind: 'editMessageText', at: this.now, text })
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get sends(): TransportCall[] {
|
|
153
|
+
return this.calls.filter((c) => c.kind === 'sendMessage')
|
|
154
|
+
}
|
|
155
|
+
get edits(): TransportCall[] {
|
|
156
|
+
return this.calls.filter((c) => c.kind === 'editMessageText')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Drive the REAL tick across a silent window, the way the gateway's 6s
|
|
161
|
+
* setInterval does: same view fields, real deps thunks. */
|
|
162
|
+
function runSilentWindow(harness: TurnHarness, turnStart: number, windowMs: number): void {
|
|
163
|
+
const deps = harness.deps(() => 'placeholder')
|
|
164
|
+
for (let now = turnStart; now <= turnStart + windowMs; now += FEED_HEARTBEAT_TICK_MS) {
|
|
165
|
+
harness.now = now
|
|
166
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
167
|
+
{
|
|
168
|
+
mirrorLineCount: 0,
|
|
169
|
+
activityMessageId: harness.activityMessageId,
|
|
170
|
+
labeledToolCount: 0,
|
|
171
|
+
ageMs: now - turnStart,
|
|
172
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
173
|
+
},
|
|
174
|
+
deps,
|
|
175
|
+
)
|
|
176
|
+
expect(handled).toBe(true) // 0-label turn: this branch owns every tick
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe('deterministic silent-turn card climb — real tick against a spy transport', () => {
|
|
181
|
+
it('a ~40s silent tool call yields >=4 card edits with climbing elapsed (never freezes)', () => {
|
|
182
|
+
const harness = new TurnHarness()
|
|
183
|
+
const turnStart = 1_000_000 // arbitrary simulated epoch
|
|
184
|
+
// Pre-open the card the way production does (early open ~1.2s in via the
|
|
185
|
+
// shared OPEN path) — the freeze reproduced specifically on an OPEN card.
|
|
186
|
+
harness.now = turnStart + FEED_LIVENESS_OPEN_MS
|
|
187
|
+
harness.deps(() => 'placeholder').openLivenessFeedIfDue()
|
|
188
|
+
expect(harness.activityMessageId).not.toBeNull()
|
|
189
|
+
|
|
190
|
+
runSilentWindow(harness, turnStart, 40_000)
|
|
191
|
+
|
|
192
|
+
// Exactly one OPEN (the pre-opened card) — the climb never sends.
|
|
193
|
+
expect(harness.sends.length).toBe(1)
|
|
194
|
+
|
|
195
|
+
// The acceptance criterion: the card is EDITED at least 4 times across the gap.
|
|
196
|
+
expect(
|
|
197
|
+
harness.edits.length,
|
|
198
|
+
`expected >=4 climbing card edits across a 40s silent tool, got ${harness.edits.length} ` +
|
|
199
|
+
`(pre-fix code freezes the 0-label card and produces 0 edits)`,
|
|
200
|
+
).toBeGreaterThanOrEqual(4)
|
|
201
|
+
|
|
202
|
+
// The elapsed on each successive edit strictly climbs — no frozen counter.
|
|
203
|
+
const elapsedSeries = harness.edits
|
|
204
|
+
.map((c) => parseHeaderElapsedSeconds(c.text))
|
|
205
|
+
.filter((n): n is number => n != null)
|
|
206
|
+
expect(elapsedSeries.length).toBe(harness.edits.length)
|
|
207
|
+
for (let i = 1; i < elapsedSeries.length; i++) {
|
|
208
|
+
expect(
|
|
209
|
+
elapsedSeries[i],
|
|
210
|
+
`card elapsed must climb: edit[${i}]=${elapsedSeries[i]}s not > edit[${i - 1}]=${elapsedSeries[i - 1]}s`,
|
|
211
|
+
).toBeGreaterThan(elapsedSeries[i - 1])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Worst-case visible-update gap stays within ~two tick windows (RFC bound).
|
|
215
|
+
const editTimes = [harness.sends[0].at, ...harness.edits.map((c) => c.at)]
|
|
216
|
+
for (let i = 1; i < editTimes.length; i++) {
|
|
217
|
+
expect(editTimes[i] - editTimes[i - 1]).toBeLessThanOrEqual(2 * FEED_HEARTBEAT_TICK_MS)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('every climb mutation is an editMessageText, never a new sendMessage (edits do not push-notify)', () => {
|
|
222
|
+
const harness = new TurnHarness()
|
|
223
|
+
const turnStart = 2_000_000
|
|
224
|
+
harness.now = turnStart + FEED_LIVENESS_OPEN_MS
|
|
225
|
+
harness.deps(() => 'placeholder').openLivenessFeedIfDue()
|
|
226
|
+
runSilentWindow(harness, turnStart, 40_000)
|
|
227
|
+
|
|
228
|
+
expect(harness.calls[0].kind).toBe('sendMessage')
|
|
229
|
+
for (const call of harness.calls.slice(1)) {
|
|
230
|
+
expect(call.kind).toBe('editMessageText')
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('each climb edit passes the full shipped gate sequence in order (pendingRender → cardDrainGate → mayDrain → openOrEditCard → drain)', () => {
|
|
235
|
+
const harness = new TurnHarness()
|
|
236
|
+
harness.activityMessageId = 55 // card already open
|
|
237
|
+
harness.now = 12_000
|
|
238
|
+
runSilentTurnHeartbeatTick(
|
|
239
|
+
{
|
|
240
|
+
mirrorLineCount: 0,
|
|
241
|
+
activityMessageId: harness.activityMessageId,
|
|
242
|
+
labeledToolCount: 0,
|
|
243
|
+
ageMs: 12_000,
|
|
244
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
245
|
+
},
|
|
246
|
+
harness.deps(() => null),
|
|
247
|
+
)
|
|
248
|
+
expect(harness.order).toEqual([
|
|
249
|
+
'setPendingRender',
|
|
250
|
+
'cardDrainGate',
|
|
251
|
+
'mayDrain',
|
|
252
|
+
'openOrEditCard',
|
|
253
|
+
'drain',
|
|
254
|
+
])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('a drain already in flight (mayDrain=false) suppresses the edit — single-flight honoured', () => {
|
|
258
|
+
const harness = new TurnHarness()
|
|
259
|
+
harness.activityMessageId = 55
|
|
260
|
+
harness.mayDrainResult = false
|
|
261
|
+
harness.now = 12_000
|
|
262
|
+
runSilentTurnHeartbeatTick(
|
|
263
|
+
{
|
|
264
|
+
mirrorLineCount: 0,
|
|
265
|
+
activityMessageId: 55,
|
|
266
|
+
labeledToolCount: 0,
|
|
267
|
+
ageMs: 12_000,
|
|
268
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
269
|
+
},
|
|
270
|
+
harness.deps(() => null),
|
|
271
|
+
)
|
|
272
|
+
expect(harness.edits.length).toBe(0)
|
|
273
|
+
// The gate was consulted, then the drain was NOT reached.
|
|
274
|
+
expect(harness.order).toContain('mayDrain')
|
|
275
|
+
expect(harness.order).not.toContain('drain')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('the climb yields to model narration (a surfaced tool label hands the tick to the labelled branch)', () => {
|
|
279
|
+
const harness = new TurnHarness()
|
|
280
|
+
harness.activityMessageId = 55
|
|
281
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
282
|
+
{
|
|
283
|
+
mirrorLineCount: 3, // narration / tool labels surfaced
|
|
284
|
+
activityMessageId: 55,
|
|
285
|
+
labeledToolCount: 2,
|
|
286
|
+
ageMs: 30_000,
|
|
287
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
288
|
+
},
|
|
289
|
+
harness.deps(() => null),
|
|
290
|
+
)
|
|
291
|
+
expect(handled).toBe(false) // labelled branch owns the tick
|
|
292
|
+
expect(harness.calls.length).toBe(0)
|
|
293
|
+
expect(harness.order.length).toBe(0)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('the stale-guard: a turn younger than minStaleMs produces no climb edit (labelled-branch symmetry)', () => {
|
|
297
|
+
const harness = new TurnHarness()
|
|
298
|
+
harness.activityMessageId = 55
|
|
299
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
300
|
+
{
|
|
301
|
+
mirrorLineCount: 0,
|
|
302
|
+
activityMessageId: 55,
|
|
303
|
+
labeledToolCount: 0,
|
|
304
|
+
ageMs: FEED_HEARTBEAT_MIN_STALE_MS - 1,
|
|
305
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
306
|
+
},
|
|
307
|
+
harness.deps(() => null),
|
|
308
|
+
)
|
|
309
|
+
expect(handled).toBe(true) // still the 0-label branch's tick
|
|
310
|
+
expect(harness.calls.length).toBe(0)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('the climb does not OPEN a card — no card yet delegates to the one shared OPEN path', () => {
|
|
314
|
+
const harness = new TurnHarness()
|
|
315
|
+
harness.now = 20_000
|
|
316
|
+
runSilentTurnHeartbeatTick(
|
|
317
|
+
{
|
|
318
|
+
mirrorLineCount: 0,
|
|
319
|
+
activityMessageId: null,
|
|
320
|
+
labeledToolCount: 0,
|
|
321
|
+
ageMs: 20_000,
|
|
322
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
323
|
+
},
|
|
324
|
+
harness.deps(() => 'open-render'),
|
|
325
|
+
)
|
|
326
|
+
// Delegated to openLivenessFeedIfDue; nothing else ran.
|
|
327
|
+
expect(harness.order).toEqual(['openLivenessFeedIfDue'])
|
|
328
|
+
expect(harness.sends.length).toBe(1) // the OPEN path opened it
|
|
329
|
+
expect(harness.edits.length).toBe(0)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('silentTurnClimbRender refuses when no card is open (opening is reply-is-last gated elsewhere)', () => {
|
|
333
|
+
expect(
|
|
334
|
+
silentTurnClimbRender({ mirrorLineCount: 0, activityMessageId: null, labeledToolCount: 0, ageMs: 30_000 }),
|
|
335
|
+
).toBeNull()
|
|
336
|
+
})
|
|
337
|
+
})
|
|
@@ -1296,6 +1296,145 @@ describe('startSubagentWatcher', () => {
|
|
|
1296
1296
|
})
|
|
1297
1297
|
})
|
|
1298
1298
|
|
|
1299
|
+
// ─── deterministic-turn-liveness.md Known-Gap 2 — worktree-isolated sub-agents ──
|
|
1300
|
+
|
|
1301
|
+
describe('worktree-isolated sub-agent visibility (turn-liveness Known Gap 2)', () => {
|
|
1302
|
+
/**
|
|
1303
|
+
* A sub-agent dispatched with `switchroom worktree claim` runs under a
|
|
1304
|
+
* different cwd than the parent agent, so Claude Code mints a
|
|
1305
|
+
* *different* `.claude/projects/<slug>/` tree for it. Pre-fix, the
|
|
1306
|
+
* #1116 foreign-slug filter above skips that slug forever — no
|
|
1307
|
+
* activity stamp, no `🛠 Worker` feed, for the whole worktree worker's
|
|
1308
|
+
* run. `extraWatchCwdsProvider` closes that gap: any cwd it returns is
|
|
1309
|
+
* treated as a first-class watched slug, same as `agentCwd`.
|
|
1310
|
+
*/
|
|
1311
|
+
|
|
1312
|
+
let tmpRoot = ''
|
|
1313
|
+
const startedWatchers: Array<{ stop(): void }> = []
|
|
1314
|
+
|
|
1315
|
+
beforeEach(() => {
|
|
1316
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-worktree-gap2-'))
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
afterEach(() => {
|
|
1320
|
+
while (startedWatchers.length) {
|
|
1321
|
+
try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
|
|
1322
|
+
}
|
|
1323
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
function startWatcherSync(opts: {
|
|
1327
|
+
agentDir: string
|
|
1328
|
+
agentCwd?: string
|
|
1329
|
+
extraWatchCwdsProvider?: () => string[]
|
|
1330
|
+
}): {
|
|
1331
|
+
logs: string[]
|
|
1332
|
+
poll: () => void
|
|
1333
|
+
watcher: ReturnType<typeof startSubagentWatcher>
|
|
1334
|
+
} {
|
|
1335
|
+
const logs: string[] = []
|
|
1336
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
1337
|
+
let nextRef = 1
|
|
1338
|
+
const watcher = startSubagentWatcher({
|
|
1339
|
+
agentDir: opts.agentDir,
|
|
1340
|
+
...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
|
|
1341
|
+
...(opts.extraWatchCwdsProvider !== undefined
|
|
1342
|
+
? { extraWatchCwdsProvider: opts.extraWatchCwdsProvider }
|
|
1343
|
+
: {}),
|
|
1344
|
+
stallThresholdMs: 60_000,
|
|
1345
|
+
rescanMs: 500,
|
|
1346
|
+
now: () => Date.now(),
|
|
1347
|
+
setInterval: (fn) => {
|
|
1348
|
+
const ref = nextRef++
|
|
1349
|
+
intervals.push({ fn, ref })
|
|
1350
|
+
return { ref }
|
|
1351
|
+
},
|
|
1352
|
+
clearInterval: () => {},
|
|
1353
|
+
setTimeout: () => ({ ref: 0 }),
|
|
1354
|
+
clearTimeout: () => {},
|
|
1355
|
+
log: (msg) => logs.push(msg),
|
|
1356
|
+
})
|
|
1357
|
+
startedWatchers.push(watcher)
|
|
1358
|
+
return { logs, poll: () => intervals[0]?.fn(), watcher }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
it('watches a worktree-isolated cwd slug returned by extraWatchCwdsProvider', () => {
|
|
1362
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1363
|
+
const agentCwd = '/home/test/agent-own'
|
|
1364
|
+
const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
|
|
1365
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
1366
|
+
const worktreeSlug = join(
|
|
1367
|
+
agentDir,
|
|
1368
|
+
'.claude',
|
|
1369
|
+
'projects',
|
|
1370
|
+
'-home-test--switchroom-worktrees-wt-abc123',
|
|
1371
|
+
'sess-B',
|
|
1372
|
+
'subagents',
|
|
1373
|
+
)
|
|
1374
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
1375
|
+
mkdirSync(worktreeSlug, { recursive: true })
|
|
1376
|
+
writeFileSync(
|
|
1377
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
1378
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
1379
|
+
)
|
|
1380
|
+
writeFileSync(
|
|
1381
|
+
join(worktreeSlug, 'agent-worktreeworker.jsonl'),
|
|
1382
|
+
buildJSONL(subAgentUserMsg('worktree-isolated work')),
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
// Pre-fix acceptance: without extraWatchCwdsProvider, the worktree
|
|
1386
|
+
// slug is a "foreign" slug per #1116 and is skipped — this is the
|
|
1387
|
+
// regression this test guards against.
|
|
1388
|
+
const preFix = startWatcherSync({ agentDir, agentCwd })
|
|
1389
|
+
preFix.poll()
|
|
1390
|
+
expect(preFix.watcher.getRegistry().has('worktreeworker')).toBe(false)
|
|
1391
|
+
preFix.watcher.stop()
|
|
1392
|
+
|
|
1393
|
+
const h = startWatcherSync({
|
|
1394
|
+
agentDir,
|
|
1395
|
+
agentCwd,
|
|
1396
|
+
extraWatchCwdsProvider: () => [worktreeCwd],
|
|
1397
|
+
})
|
|
1398
|
+
h.poll()
|
|
1399
|
+
|
|
1400
|
+
const reg = h.watcher.getRegistry()
|
|
1401
|
+
expect(reg.has('ownworker')).toBe(true)
|
|
1402
|
+
expect(reg.has('worktreeworker')).toBe(true)
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
it('still skips a genuinely foreign project dir not returned by extraWatchCwdsProvider', () => {
|
|
1406
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1407
|
+
const agentCwd = '/home/test/agent-own'
|
|
1408
|
+
const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
|
|
1409
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
1410
|
+
const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-C', 'subagents')
|
|
1411
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
1412
|
+
mkdirSync(foreignSubagents, { recursive: true })
|
|
1413
|
+
writeFileSync(
|
|
1414
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
1415
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
1416
|
+
)
|
|
1417
|
+
writeFileSync(
|
|
1418
|
+
join(foreignSubagents, 'agent-foreignworker.jsonl'),
|
|
1419
|
+
buildJSONL(subAgentUserMsg('a sibling agent, not a registered worktree')),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
const h = startWatcherSync({
|
|
1423
|
+
agentDir,
|
|
1424
|
+
agentCwd,
|
|
1425
|
+
// Registers a worktree cwd that is NOT the foreign dir above —
|
|
1426
|
+
// proves extraWatchCwdsProvider adds only what it explicitly
|
|
1427
|
+
// names, never a wildcard.
|
|
1428
|
+
extraWatchCwdsProvider: () => [worktreeCwd],
|
|
1429
|
+
})
|
|
1430
|
+
h.poll()
|
|
1431
|
+
|
|
1432
|
+
const reg = h.watcher.getRegistry()
|
|
1433
|
+
expect(reg.has('ownworker')).toBe(true)
|
|
1434
|
+
expect(reg.has('foreignworker')).toBe(false)
|
|
1435
|
+
})
|
|
1436
|
+
})
|
|
1437
|
+
|
|
1299
1438
|
describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
|
|
1300
1439
|
/**
|
|
1301
1440
|
* Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership predicate for the worktree-isolated cwds the subagent-watcher
|
|
3
|
+
* additionally watches (deterministic-turn-liveness.md Known Gap 2 / #2893
|
|
4
|
+
* review fix). Pins the fail-CLOSED behaviour: the review found the closure
|
|
5
|
+
* both FAILED OPEN (unset identity matched every ownerless record — the #1116
|
|
6
|
+
* leak reintroduced) and FAILED CLOSED (a plain `worktree claim` produced an
|
|
7
|
+
* ownerless record filtered out, so the sub-agent stayed invisible — Gap 2).
|
|
8
|
+
*
|
|
9
|
+
* Run with:
|
|
10
|
+
* bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
ownedWorktreeCwds,
|
|
15
|
+
type WorktreeOwnershipRecord,
|
|
16
|
+
} from "../worktree-watch-cwds.js";
|
|
17
|
+
|
|
18
|
+
const idPath = (p: string) => p; // identity realpath for deterministic tests
|
|
19
|
+
|
|
20
|
+
describe("ownedWorktreeCwds", () => {
|
|
21
|
+
const records: WorktreeOwnershipRecord[] = [
|
|
22
|
+
{ path: "/wt/mine-1", ownerAgent: "klanker" },
|
|
23
|
+
{ path: "/wt/mine-2", ownerAgent: "klanker" },
|
|
24
|
+
{ path: "/wt/theirs", ownerAgent: "reggie" },
|
|
25
|
+
{ path: "/wt/ownerless" }, // ownerAgent undefined
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
it("returns NOTHING when the agent identity is unset (fail-closed, no #1116 leak)", () => {
|
|
29
|
+
expect(
|
|
30
|
+
ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
|
|
31
|
+
).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns NOTHING when the agent identity is empty string", () => {
|
|
35
|
+
expect(
|
|
36
|
+
ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
|
|
37
|
+
).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does NOT match ownerless records even when identity is set", () => {
|
|
41
|
+
const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
|
|
42
|
+
expect(out).not.toContain("/wt/ownerless");
|
|
43
|
+
expect(out).not.toContain("/wt/theirs");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("includes exactly the records this agent owns", () => {
|
|
47
|
+
expect(
|
|
48
|
+
ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath }),
|
|
49
|
+
).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns [] when the registry read throws (best-effort; base watch undisturbed)", () => {
|
|
53
|
+
expect(
|
|
54
|
+
ownedWorktreeCwds({
|
|
55
|
+
self: "klanker",
|
|
56
|
+
listRecords: () => {
|
|
57
|
+
throw new Error("registry unavailable");
|
|
58
|
+
},
|
|
59
|
+
realpath: idPath,
|
|
60
|
+
}),
|
|
61
|
+
).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("realpaths owned paths (symlinked-base slug fix), falling back on failure", () => {
|
|
65
|
+
const out = ownedWorktreeCwds({
|
|
66
|
+
self: "klanker",
|
|
67
|
+
listRecords: () => [
|
|
68
|
+
{ path: "/tmp/mine", ownerAgent: "klanker" },
|
|
69
|
+
{ path: "/gone/mine", ownerAgent: "klanker" },
|
|
70
|
+
],
|
|
71
|
+
realpath: (p) => {
|
|
72
|
+
if (p === "/tmp/mine") return "/private/tmp/mine";
|
|
73
|
+
throw new Error("ENOENT");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(out).toEqual(["/private/tmp/mine", "/gone/mine"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("two-tick mutation: a fresh claim/release is picked up without restart", () => {
|
|
80
|
+
// The provider is re-invoked every rescan tick, so a mutating registry
|
|
81
|
+
// (claim adds an owned record; release removes it) must be reflected
|
|
82
|
+
// tick-over-tick with no process restart.
|
|
83
|
+
let live: WorktreeOwnershipRecord[] = [{ path: "/wt/a", ownerAgent: "klanker" }];
|
|
84
|
+
const provider = () =>
|
|
85
|
+
ownedWorktreeCwds({ self: "klanker", listRecords: () => live, realpath: idPath });
|
|
86
|
+
|
|
87
|
+
// Tick 1: one owned worktree.
|
|
88
|
+
expect(provider()).toEqual(["/wt/a"]);
|
|
89
|
+
|
|
90
|
+
// Claim a second (as the ambient owner would default via SWITCHROOM_AGENT_NAME).
|
|
91
|
+
live = [
|
|
92
|
+
{ path: "/wt/a", ownerAgent: "klanker" },
|
|
93
|
+
{ path: "/wt/b", ownerAgent: "klanker" },
|
|
94
|
+
];
|
|
95
|
+
// Tick 2: both picked up, no restart.
|
|
96
|
+
expect(provider()).toEqual(["/wt/a", "/wt/b"]);
|
|
97
|
+
|
|
98
|
+
// Release the first.
|
|
99
|
+
live = [{ path: "/wt/b", ownerAgent: "klanker" }];
|
|
100
|
+
// Tick 3: reflects the release.
|
|
101
|
+
expect(provider()).toEqual(["/wt/b"]);
|
|
102
|
+
});
|
|
103
|
+
});
|