switchroom 0.17.10 → 0.18.3
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +27 -1
- package/dist/auth-broker/index.js +6161 -151
- package/dist/cli/notion-write-pretool.mjs +29 -2
- package/dist/cli/switchroom.js +578 -454
- package/dist/host-control/main.js +6182 -172
- package/dist/vault/approvals/kernel-server.js +5891 -164
- package/dist/vault/broker/server.js +6597 -881
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +2 -2
- 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/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +480 -85
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -40,7 +40,12 @@ import { describe, it, expect } from 'vitest'
|
|
|
40
40
|
import * as realFs from 'fs'
|
|
41
41
|
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
42
42
|
import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
clipNarrative,
|
|
45
|
+
appendActivityLabel,
|
|
46
|
+
renderActivityFeedWithNested,
|
|
47
|
+
formatStepSuffix,
|
|
48
|
+
} from '../tool-activity-summary.js'
|
|
44
49
|
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
45
50
|
import {
|
|
46
51
|
createWorkerActivityFeed,
|
|
@@ -249,6 +254,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
249
254
|
finalAnswerDeliveredAt: currentTurn!.finalAnswerDeliveredAt,
|
|
250
255
|
now: currentTime + 5, // a heartbeat tick moments after the stamp
|
|
251
256
|
staleCapMs: 30_000,
|
|
257
|
+
stillDispatched: false,
|
|
252
258
|
})
|
|
253
259
|
expect(verdictInWindow).toBe('emit')
|
|
254
260
|
})
|
|
@@ -262,6 +268,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
262
268
|
finalAnswerDeliveredAt: 1000,
|
|
263
269
|
now: 50_000,
|
|
264
270
|
staleCapMs: 30_000,
|
|
271
|
+
stillDispatched: false,
|
|
265
272
|
})
|
|
266
273
|
expect(verdict).toBe('idle')
|
|
267
274
|
})
|
|
@@ -273,6 +280,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
273
280
|
finalAnswerDeliveredAt: 1000,
|
|
274
281
|
now: 1500,
|
|
275
282
|
staleCapMs: 30_000,
|
|
283
|
+
stillDispatched: false,
|
|
276
284
|
}),
|
|
277
285
|
).toBe('idle')
|
|
278
286
|
})
|
|
@@ -310,9 +318,14 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
310
318
|
|
|
311
319
|
// The gateway's feedHeartbeatTick post-answer entry, reduced to its decision:
|
|
312
320
|
// `if (turn == null) return` (no-turn), else the REAL evaluatePostAnswerLiveness.
|
|
321
|
+
// `stillDispatched` defaults to false (a purely-background worker, no
|
|
322
|
+
// foreground `turn.foregroundSubAgents` tracking) so these existing tests
|
|
323
|
+
// keep proving the ORIGINAL staleness-cap behaviour is preserved for that
|
|
324
|
+
// case; a dedicated Fix-3 describe block below covers `stillDispatched: true`.
|
|
313
325
|
function heartbeatVerdict(
|
|
314
326
|
currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
|
|
315
327
|
now: number,
|
|
328
|
+
stillDispatched = false,
|
|
316
329
|
): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
|
|
317
330
|
const turn = currentTurn
|
|
318
331
|
if (turn == null) return 'no-turn' // gateway: `if (turn == null) return`
|
|
@@ -322,6 +335,7 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
322
335
|
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
323
336
|
now,
|
|
324
337
|
staleCapMs: 30_000,
|
|
338
|
+
stillDispatched,
|
|
325
339
|
})
|
|
326
340
|
}
|
|
327
341
|
|
|
@@ -377,6 +391,146 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
377
391
|
})
|
|
378
392
|
})
|
|
379
393
|
|
|
394
|
+
// ─── Fix 3 — sub-agent-delegation freeze (operator-confirmed: card frozen on
|
|
395
|
+
// "Running a command" for 41s / 3m12s while a sub-agent ran underneath) ──
|
|
396
|
+
|
|
397
|
+
describe('Fix 3: a still-tracked foreground sub-agent bypasses the staleness cap (no mid-delegation freeze)', () => {
|
|
398
|
+
/**
|
|
399
|
+
* Reproduces the EXACT scenario the operator's 3 screenshots showed: a
|
|
400
|
+
* foreground `Task`/`Agent` dispatch is still outstanding (one long silent
|
|
401
|
+
* step — no new watcher narrative tick), so `subagentActivityAt` stops
|
|
402
|
+
* advancing, yet the worker has NOT reported finished. Before Fix 3 the
|
|
403
|
+
* staleness cap could not tell this apart from genuine completion and froze
|
|
404
|
+
* the card after 30s regardless. `stillDispatched: true` — sourced from
|
|
405
|
+
* `turn.foregroundSubAgents.size > 0` at the real call site — is the
|
|
406
|
+
* positive signal that closes the gap.
|
|
407
|
+
*/
|
|
408
|
+
|
|
409
|
+
// Local copy of the gateway's post-answer decision (same as the "Fix 2 /
|
|
410
|
+
// concern 2" describe block above — duplicated here rather than hoisted to
|
|
411
|
+
// module scope so each describe stays self-contained/readable).
|
|
412
|
+
function heartbeatVerdict(
|
|
413
|
+
currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
|
|
414
|
+
now: number,
|
|
415
|
+
stillDispatched = false,
|
|
416
|
+
): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
|
|
417
|
+
const turn = currentTurn
|
|
418
|
+
if (turn == null) return 'no-turn'
|
|
419
|
+
if (!turn.finalAnswerDelivered) return 'pre-answer'
|
|
420
|
+
return evaluatePostAnswerLiveness({
|
|
421
|
+
subagentActivityAt: turn.subagentActivityAt,
|
|
422
|
+
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
423
|
+
now,
|
|
424
|
+
staleCapMs: 30_000,
|
|
425
|
+
stillDispatched,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
it('long silent single step (41s, no new narrative) — bypasses the cap while still dispatched', () => {
|
|
430
|
+
const turn = {
|
|
431
|
+
finalAnswerDelivered: true,
|
|
432
|
+
finalAnswerDeliveredAt: 1000,
|
|
433
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
434
|
+
}
|
|
435
|
+
const currentTurn: typeof turn | null = turn
|
|
436
|
+
// 41s after the last (and only) narrative advance — well past the 30s cap.
|
|
437
|
+
// Legacy (stillDispatched: false) behaviour would already be 'stale' here
|
|
438
|
+
// (proven by the concern-3 test above); with a tracked foreground worker
|
|
439
|
+
// it must stay 'emit' so the card keeps climbing instead of freezing.
|
|
440
|
+
expect(heartbeatVerdict(currentTurn, 2000 + 41_000, /* stillDispatched */ true)).toBe('emit')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('long silent single step (3m12s) — still bypasses the cap while still dispatched', () => {
|
|
444
|
+
const turn = {
|
|
445
|
+
finalAnswerDelivered: true,
|
|
446
|
+
finalAnswerDeliveredAt: 1000,
|
|
447
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
448
|
+
}
|
|
449
|
+
const currentTurn: typeof turn | null = turn
|
|
450
|
+
const threeMinTwelveSecMs = 3 * 60_000 + 12_000
|
|
451
|
+
expect(heartbeatVerdict(currentTurn, 2000 + threeMinTwelveSecMs, true)).toBe('emit')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('once the foreground sub-agent is no longer tracked (finished), the cap re-applies normally', () => {
|
|
455
|
+
// The gateway removes the agentId from `turn.foregroundSubAgents` when it
|
|
456
|
+
// finishes (gateway.ts ~27032/27037) — `stillDispatched` then reads false
|
|
457
|
+
// again and the ORIGINAL runaway-climb protection re-engages exactly as
|
|
458
|
+
// before Fix 3. This is the adversarial check that Fix 3 doesn't remove
|
|
459
|
+
// the cap's protection outright, only bypasses it while genuinely unknown.
|
|
460
|
+
const turn = {
|
|
461
|
+
finalAnswerDelivered: true,
|
|
462
|
+
finalAnswerDeliveredAt: 1000,
|
|
463
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
464
|
+
}
|
|
465
|
+
const currentTurn: typeof turn | null = turn
|
|
466
|
+
expect(heartbeatVerdict(currentTurn, 2000 + 41_000, false)).toBe('stale')
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('idle-gap suppression still wins over stillDispatched (no watcher activity ever ⇒ silent, reply-is-last preserved)', () => {
|
|
470
|
+
// Adversarial check: a turn that dispatched a foreground sub-agent but has
|
|
471
|
+
// had NO watcher tick at all since the answer (subagentActivityAt
|
|
472
|
+
// undefined) must stay 'idle', never 'emit', even if stillDispatched is
|
|
473
|
+
// true — a still-registered worker with zero ticks yet is not the same as
|
|
474
|
+
// one whose last tick went stale. This preserves the reply-is-last
|
|
475
|
+
// invariant for a turn that answered and dispatched but hasn't surfaced
|
|
476
|
+
// any post-answer step yet.
|
|
477
|
+
const turn = {
|
|
478
|
+
finalAnswerDelivered: true,
|
|
479
|
+
finalAnswerDeliveredAt: 1000,
|
|
480
|
+
subagentActivityAt: undefined as number | undefined,
|
|
481
|
+
}
|
|
482
|
+
const currentTurn: typeof turn | null = turn
|
|
483
|
+
expect(heartbeatVerdict(currentTurn, 50_000, true)).toBe('idle')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('end-to-end: the RENDERED card text keeps changing across real heartbeat ticks while a foreground sub-agent is silently working (not just the verdict function)', () => {
|
|
487
|
+
// Full simulated turn: dispatch a foreground Task, one narrative tick
|
|
488
|
+
// lands, then NOTHING further for over three minutes (one long silent
|
|
489
|
+
// Bash inside the sub-agent) — the exact operator-reported shape. Drives
|
|
490
|
+
// the REAL `evaluatePostAnswerLiveness` verdict AND the REAL
|
|
491
|
+
// `renderActivityFeedWithNested`/`composeTurnActivity`-shaped render at
|
|
492
|
+
// each tick, asserting the VISIBLE text (not an internal label) actually
|
|
493
|
+
// climbs instead of freezing.
|
|
494
|
+
const turn = {
|
|
495
|
+
finalAnswerDelivered: true,
|
|
496
|
+
finalAnswerDeliveredAt: 1_000,
|
|
497
|
+
subagentActivityAt: 2_000 as number | undefined,
|
|
498
|
+
mirrorLines: ['Delegating to a sub-agent'],
|
|
499
|
+
foregroundSubAgents: new Map<string, string[]>([['agent-1', ['Running a command']]]),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderCardAt(now: number): string | null {
|
|
503
|
+
const verdict = evaluatePostAnswerLiveness({
|
|
504
|
+
subagentActivityAt: turn.subagentActivityAt,
|
|
505
|
+
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
506
|
+
now,
|
|
507
|
+
staleCapMs: 30_000,
|
|
508
|
+
stillDispatched: turn.foregroundSubAgents.size > 0,
|
|
509
|
+
})
|
|
510
|
+
if (verdict !== 'emit') return null // the pre-Fix-3 freeze point
|
|
511
|
+
const childLines = [...turn.foregroundSubAgents.values()].flat()
|
|
512
|
+
const header = { label: 'Agent', elapsedMs: now - 0, toolCount: 1, state: 'running' as const }
|
|
513
|
+
return renderActivityFeedWithNested(turn.mirrorLines, childLines, false, formatStepSuffix(now - turn.subagentActivityAt!), undefined, header)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const tick1 = renderCardAt(2_000 + 6_000) // 6s after the last tick
|
|
517
|
+
const tick2 = renderCardAt(2_000 + 41_000) // 41s — the first operator-reported freeze point
|
|
518
|
+
const tick3 = renderCardAt(2_000 + (3 * 60_000 + 12_000)) // 3m12s — the second
|
|
519
|
+
|
|
520
|
+
expect(tick1).not.toBeNull()
|
|
521
|
+
expect(tick2).not.toBeNull()
|
|
522
|
+
expect(tick3).not.toBeNull()
|
|
523
|
+
// The visible text must actually differ tick-over-tick (climbing elapsed),
|
|
524
|
+
// not be frozen at the same string for minutes.
|
|
525
|
+
expect(tick2).not.toBe(tick1)
|
|
526
|
+
expect(tick3).not.toBe(tick2)
|
|
527
|
+
// And it must contain the growing wall-clock evidence the operator was
|
|
528
|
+
// missing — a live elapsed suffix on the nested step.
|
|
529
|
+
expect(tick2).toContain('41s')
|
|
530
|
+
expect(tick3).toContain('3m12s')
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
380
534
|
// ─── Fix 2 — concern 2 resolution: the decoupled worker surfaces via the
|
|
381
535
|
// currentTurn-INDEPENDENT workerActivityFeed (and is bounded) ────────────
|
|
382
536
|
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
* Run with:
|
|
10
10
|
* bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
|
|
11
11
|
*/
|
|
12
|
-
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
13
13
|
import {
|
|
14
14
|
ownedWorktreeCwds,
|
|
15
|
+
__resetIdentityEscalationForTests,
|
|
15
16
|
type WorktreeOwnershipRecord,
|
|
16
17
|
} from "../worktree-watch-cwds.js";
|
|
17
18
|
|
|
18
19
|
const idPath = (p: string) => p; // identity realpath for deterministic tests
|
|
19
20
|
|
|
21
|
+
beforeEach(() => __resetIdentityEscalationForTests());
|
|
22
|
+
|
|
20
23
|
describe("ownedWorktreeCwds", () => {
|
|
21
24
|
const records: WorktreeOwnershipRecord[] = [
|
|
22
25
|
{ path: "/wt/mine-1", ownerAgent: "klanker" },
|
|
@@ -25,18 +28,110 @@ describe("ownedWorktreeCwds", () => {
|
|
|
25
28
|
{ path: "/wt/ownerless" }, // ownerAgent undefined
|
|
26
29
|
];
|
|
27
30
|
|
|
28
|
-
it("returns NOTHING when the agent identity is unset (fail-closed, no #1116 leak)", () => {
|
|
31
|
+
it("returns NOTHING when the agent identity is unset and no durable fallback given (fail-closed, no #1116 leak)", () => {
|
|
29
32
|
expect(
|
|
30
33
|
ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
|
|
31
34
|
).toEqual([]);
|
|
32
35
|
});
|
|
33
36
|
|
|
34
|
-
it("returns NOTHING when the agent identity is empty string", () => {
|
|
37
|
+
it("returns NOTHING when the agent identity is empty string and no durable fallback given", () => {
|
|
35
38
|
expect(
|
|
36
39
|
ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
|
|
37
40
|
).toEqual([]);
|
|
38
41
|
});
|
|
39
42
|
|
|
43
|
+
// ---- Layer 2: durable, non-env identity fallback (#1116 / #2893) ----
|
|
44
|
+
|
|
45
|
+
it("(a) env SET → ownership resolves exactly as before (fast path unchanged)", () => {
|
|
46
|
+
expect(
|
|
47
|
+
ownedWorktreeCwds({
|
|
48
|
+
self: "klanker",
|
|
49
|
+
agentDir: "/home/x/.switchroom/agents/SHOULD_BE_IGNORED",
|
|
50
|
+
listRecords: () => records,
|
|
51
|
+
realpath: idPath,
|
|
52
|
+
}),
|
|
53
|
+
).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("(b) env UNSET but agentDir present → identity derived from dir basename, ownership resolves", () => {
|
|
57
|
+
const out = ownedWorktreeCwds({
|
|
58
|
+
self: undefined,
|
|
59
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
60
|
+
listRecords: () => records,
|
|
61
|
+
realpath: idPath,
|
|
62
|
+
});
|
|
63
|
+
expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("(b) env EMPTY but agentDir present → same durable derivation", () => {
|
|
67
|
+
const out = ownedWorktreeCwds({
|
|
68
|
+
self: "",
|
|
69
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
70
|
+
listRecords: () => records,
|
|
71
|
+
realpath: idPath,
|
|
72
|
+
});
|
|
73
|
+
expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("(b) durable fallback NEVER mis-attributes: a dir for a different agent matches only THAT agent's records, never klanker's", () => {
|
|
77
|
+
// agentDir names 'reggie' → resolves reggie's worktrees, never klanker's
|
|
78
|
+
// or the ownerless ones. A wrong basename fails-closed to [], it can never
|
|
79
|
+
// attribute to the WRONG owner.
|
|
80
|
+
const out = ownedWorktreeCwds({
|
|
81
|
+
self: undefined,
|
|
82
|
+
agentDir: "/home/x/.switchroom/agents/reggie",
|
|
83
|
+
listRecords: () => records,
|
|
84
|
+
realpath: idPath,
|
|
85
|
+
});
|
|
86
|
+
expect(out).toEqual(["/wt/theirs"]);
|
|
87
|
+
expect(out).not.toContain("/wt/ownerless");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("(c) BOTH env and agentDir unavailable → returns [] AND emits an escalated ERROR (no throw, no mis-attribution)", () => {
|
|
91
|
+
const logs: string[] = [];
|
|
92
|
+
const out = ownedWorktreeCwds({
|
|
93
|
+
self: undefined,
|
|
94
|
+
agentDir: undefined,
|
|
95
|
+
listRecords: () => records,
|
|
96
|
+
realpath: idPath,
|
|
97
|
+
log: (m) => logs.push(m),
|
|
98
|
+
});
|
|
99
|
+
expect(out).toEqual([]);
|
|
100
|
+
expect(logs).toHaveLength(1);
|
|
101
|
+
expect(logs[0]).toContain("ERROR");
|
|
102
|
+
expect(logs[0]).toContain("identity resolution FAILED");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("(c) blank agentDir (whitespace) is treated as unavailable → [] + escalated error", () => {
|
|
106
|
+
const logs: string[] = [];
|
|
107
|
+
const out = ownedWorktreeCwds({
|
|
108
|
+
self: "",
|
|
109
|
+
agentDir: " ",
|
|
110
|
+
listRecords: () => records,
|
|
111
|
+
realpath: idPath,
|
|
112
|
+
log: (m) => logs.push(m),
|
|
113
|
+
});
|
|
114
|
+
expect(out).toEqual([]);
|
|
115
|
+
expect(logs).toHaveLength(1);
|
|
116
|
+
expect(logs[0]).toContain("identity resolution FAILED");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("(c) the escalated error is one-shot per process (not re-logged every rescan tick)", () => {
|
|
120
|
+
const logs: string[] = [];
|
|
121
|
+
const call = () =>
|
|
122
|
+
ownedWorktreeCwds({
|
|
123
|
+
self: undefined,
|
|
124
|
+
agentDir: undefined,
|
|
125
|
+
listRecords: () => records,
|
|
126
|
+
realpath: idPath,
|
|
127
|
+
log: (m) => logs.push(m),
|
|
128
|
+
});
|
|
129
|
+
call();
|
|
130
|
+
call();
|
|
131
|
+
call();
|
|
132
|
+
expect(logs).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
40
135
|
it("does NOT match ownerless records even when identity is set", () => {
|
|
41
136
|
const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
|
|
42
137
|
expect(out).not.toContain("/wt/ownerless");
|
|
@@ -227,13 +227,47 @@ export interface PostAnswerLivenessInput {
|
|
|
227
227
|
now: number
|
|
228
228
|
/** Staleness cap in ms; `<= 0` disables the cap. */
|
|
229
229
|
staleCapMs: number
|
|
230
|
+
/**
|
|
231
|
+
* A POSITIVE, independent signal that a sub-agent dispatch is still known to
|
|
232
|
+
* be outstanding (e.g. `turn.foregroundSubAgents.size > 0` — a foreground
|
|
233
|
+
* `Task`/`Agent` this turn dispatched and has not yet reported finished).
|
|
234
|
+
*
|
|
235
|
+
* ## The gap this closes
|
|
236
|
+
*
|
|
237
|
+
* The staleness cap above was built to stop the card climbing FOREVER once
|
|
238
|
+
* a worker's `onFinish` froze `subagentActivityAt` and nothing further would
|
|
239
|
+
* ever arrive — but the ONLY signal it read (`now - subagentActivityAt`) is
|
|
240
|
+
* identical whether the worker (a) actually finished, or (b) is still
|
|
241
|
+
* genuinely running a SINGLE long silent step (one long Bash call, a slow
|
|
242
|
+
* fetch) that simply hasn't produced a NEW distinguishable watcher tick.
|
|
243
|
+
* Case (b) is exactly the scenario `feed-heartbeat-climb.ts`'s 0-label climb
|
|
244
|
+
* and `worker-activity-feed.ts`'s own heartbeat both exist to handle
|
|
245
|
+
* deterministically elsewhere — this post-answer branch alone lacked that
|
|
246
|
+
* fallback, so it froze the card mid-delegation (the confirmed operator
|
|
247
|
+
* symptom: "Running a command" stuck for 41s / 3m12s while a sub-agent ran
|
|
248
|
+
* underneath).
|
|
249
|
+
*
|
|
250
|
+
* When `stillDispatched` is `true` we have POSITIVE evidence the worker has
|
|
251
|
+
* not reported completion, so the staleness cap is bypassed entirely and the
|
|
252
|
+
* verdict stays `'emit'` — the caller keeps climbing the card deterministically
|
|
253
|
+
* off wall-clock elapsed exactly like the sibling 0-label/worker-feed paths.
|
|
254
|
+
* When `false` (no such tracking available — e.g. a purely-background worker
|
|
255
|
+
* with no foreground registration), the ORIGINAL cap behaviour is preserved
|
|
256
|
+
* unchanged, so the runaway-climb-after-completion protection this cap was
|
|
257
|
+
* built for still applies wherever we have no better signal.
|
|
258
|
+
*/
|
|
259
|
+
stillDispatched: boolean
|
|
230
260
|
}
|
|
231
261
|
|
|
232
262
|
export function evaluatePostAnswerLiveness(input: PostAnswerLivenessInput): PostAnswerLivenessVerdict {
|
|
233
|
-
const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs } = input
|
|
263
|
+
const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs, stillDispatched } = input
|
|
234
264
|
const answeredAt = finalAnswerDeliveredAt ?? 0
|
|
235
265
|
// idle-gap: nothing surfaced after the answer → silent (reply-is-last preserved).
|
|
236
266
|
if (subagentActivityAt == null || subagentActivityAt <= answeredAt) return 'idle'
|
|
267
|
+
// A positive "still dispatched" signal overrides the staleness cap — we KNOW
|
|
268
|
+
// the worker hasn't reported done, so a quiet stretch is a long silent step,
|
|
269
|
+
// not completion. Never freeze the card while that's true.
|
|
270
|
+
if (stillDispatched) return 'emit'
|
|
237
271
|
// staleness cap: the worker's last advance is older than the cap → stop emitting.
|
|
238
272
|
if (staleCapMs > 0 && now - subagentActivityAt >= staleCapMs) return 'stale'
|
|
239
273
|
return 'emit'
|
|
@@ -56,9 +56,20 @@
|
|
|
56
56
|
import { describe, it, expect } from "vitest";
|
|
57
57
|
import { spinUp } from "../harness.js";
|
|
58
58
|
import type { ObservedMessage } from "../driver.js";
|
|
59
|
+
import { richRenderEnabled } from "../../render/rich-render.js";
|
|
59
60
|
|
|
60
61
|
const AGENT = "test-harness";
|
|
61
62
|
|
|
63
|
+
// The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
|
|
64
|
+
// gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
|
|
65
|
+
// collapsible round-trip proof below only runs when BOTH the driver creds AND
|
|
66
|
+
// the flag are present — the flag must be set in the gateway process under
|
|
67
|
+
// test for the renderer to actually shape the outbound message, so gating the
|
|
68
|
+
// scenario on the same flag keeps it honest (no false green when the wiring
|
|
69
|
+
// isn't live). When the flag is off, this scenario self-skips green exactly
|
|
70
|
+
// like the credential-less case.
|
|
71
|
+
const RICH_RENDER_ON = richRenderEnabled();
|
|
72
|
+
|
|
62
73
|
// The driver session is the load-bearing credential. Absent it, spinUp()
|
|
63
74
|
// throws in resolveConfig — so guard at describe level and self-skip green.
|
|
64
75
|
const HAS_DRIVER_CREDS =
|
|
@@ -105,13 +116,18 @@ const SAMPLE_LINES = [
|
|
|
105
116
|
* trip on the uat-host (PR #2745 first live run: present set was
|
|
106
117
|
* `bold, italic, code, strikethrough, text_link, blockquote, pre`).
|
|
107
118
|
*
|
|
108
|
-
* `spoiler` is
|
|
109
|
-
* span did NOT surface as a `spoiler`/`textMarked` entity
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
119
|
+
* `spoiler` is soft-observed HERE only because of THIS harness's decode path:
|
|
120
|
+
* the `||…||` span did NOT reliably surface as a `spoiler`/`textMarked` entity
|
|
121
|
+
* through the MTProto send→IV→decode round trip the UAT driver uses. This is a
|
|
122
|
+
* harness-decoder limitation, NOT a wire failure: a 2026-07 direct probe of the
|
|
123
|
+
* Bot API `sendRichMessage` endpoint (the actual production send path) confirmed
|
|
124
|
+
* `||spoiler||` DOES parse to a `spoiler` entity and `==highlight==` to `marked`
|
|
125
|
+
* on the live wire — see `reference/rfcs/telegram-native-formatting.md` §6a.
|
|
126
|
+
* Spoiler ships default-on; it stays soft here purely so this MTProto-decode
|
|
127
|
+
* gate doesn't red on its own decode gap. (Underline `__…__`, by contrast, is a
|
|
128
|
+
* genuine wire exclusion — it parses as bold; see §6a.) The decoder's
|
|
129
|
+
* `textMarked → spoiler` mapping is still pinned deterministically by the
|
|
130
|
+
* hosted unit suite.
|
|
115
131
|
*
|
|
116
132
|
* Lists and dividers carry no first-class Bot API entity (they render as
|
|
117
133
|
* bulleted/numbered/rule TEXT), so they are asserted on `reply.text` below.
|
|
@@ -252,3 +268,79 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
|
|
|
252
268
|
);
|
|
253
269
|
},
|
|
254
270
|
);
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Rich-render wiring proof — expandable / collapsible content round-trip.
|
|
274
|
+
//
|
|
275
|
+
// This is the piece the unit suite CANNOT prove: that a REAL markdown reply
|
|
276
|
+
// carrying the Bot API 10.1 expandable-blockquote marker (`**> `) flows
|
|
277
|
+
// through the live send path — parse.ts (marker -> IR `expandable: true`) ->
|
|
278
|
+
// render.ts (IR -> `**> ` markdown) -> renderSafe -> sendRichMessage — and
|
|
279
|
+
// that Telegram actually parses it back to a blockquote entity on the wire.
|
|
280
|
+
//
|
|
281
|
+
// Runs ONLY when the rich-render flag is on in this process (see
|
|
282
|
+
// RICH_RENDER_ON) AND the driver creds are present; self-skips green
|
|
283
|
+
// otherwise, so default CI (flag off) never reds on it.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
const EXP_MARKER = "COLLAPSE9";
|
|
287
|
+
const EXPANDABLE_SAMPLE = [
|
|
288
|
+
`Reply with EXACTLY this and NOTHING else, keeping ${EXP_MARKER} verbatim:`,
|
|
289
|
+
"",
|
|
290
|
+
`${EXP_MARKER}: here is a collapsible section.`,
|
|
291
|
+
"",
|
|
292
|
+
"**> first hidden line of the expandable quote",
|
|
293
|
+
"> second hidden line",
|
|
294
|
+
"> third hidden line",
|
|
295
|
+
].join("\n");
|
|
296
|
+
|
|
297
|
+
(HAS_DRIVER_CREDS && RICH_RENDER_ON ? describe : describe.skip)(
|
|
298
|
+
"uat: expandable/collapsible content round-trips through the live renderer",
|
|
299
|
+
() => {
|
|
300
|
+
it(
|
|
301
|
+
"a `**>` reply parses -> renders -> sends -> decodes as a blockquote entity",
|
|
302
|
+
async () => {
|
|
303
|
+
const sc = await spinUp({ agent: AGENT });
|
|
304
|
+
try {
|
|
305
|
+
await sc.sendDM(EXPANDABLE_SAMPLE);
|
|
306
|
+
|
|
307
|
+
const reply = await sc.expectMessage(
|
|
308
|
+
(m: ObservedMessage) =>
|
|
309
|
+
m.text.includes(EXP_MARKER) || m.text === "\x01",
|
|
310
|
+
{ from: "bot", timeout: 90_000 },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Decode regression gate (same as the primary scenario).
|
|
314
|
+
expect(
|
|
315
|
+
reply.text,
|
|
316
|
+
"expandable reply decoded as the \\x01 unsupported-media sentinel",
|
|
317
|
+
).not.toBe("\x01");
|
|
318
|
+
expect(reply.text).toContain(EXP_MARKER);
|
|
319
|
+
|
|
320
|
+
// The collapsible construct must survive as a real blockquote entity
|
|
321
|
+
// on the wire — proof the parse->render->send path produced valid
|
|
322
|
+
// Bot API 10.1 markdown, not stray `**>` literal text.
|
|
323
|
+
const present = kindsPresent(reply);
|
|
324
|
+
expect(
|
|
325
|
+
[...present],
|
|
326
|
+
`expandable quote did not decode as a blockquote entity ` +
|
|
327
|
+
`(present kinds: ${[...present].join(", ")})`,
|
|
328
|
+
).toContain("blockquote");
|
|
329
|
+
|
|
330
|
+
// The quoted body text must round-trip (content never lost).
|
|
331
|
+
expect(reply.text).toContain("first hidden line");
|
|
332
|
+
|
|
333
|
+
console.info(
|
|
334
|
+
"[uat] expandable round-trip entity structure: " +
|
|
335
|
+
JSON.stringify(
|
|
336
|
+
reply.entities.map((e) => ({ kind: e.kind, text: e.text })),
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
} finally {
|
|
340
|
+
await sc.tearDown();
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
120_000,
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
);
|
|
@@ -1,29 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ownership filter for the worktree-isolated cwds the subagent-watcher should
|
|
3
3
|
* additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
|
|
4
|
-
* ownership-predicate review fix).
|
|
4
|
+
* ownership-predicate review fix + the #1116 / #2893 durable-identity fix).
|
|
5
5
|
*
|
|
6
6
|
* A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
|
|
7
7
|
* different project-dir slug than the agent's own `agentCwd`, so the #1116
|
|
8
8
|
* foreign-slug filter would skip it forever unless the watcher also watches
|
|
9
9
|
* the slugs of worktrees THIS agent owns. This helper derives that set from
|
|
10
|
-
* the host-global worktree registry,
|
|
10
|
+
* the host-global worktree registry, filtered by the agent's own identity.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
12
|
+
* Identity resolution is two-tier (durable fix for the gap where a worktree
|
|
13
|
+
* worker whose identity can't be attributed gets NO live progress feed):
|
|
14
|
+
*
|
|
15
|
+
* 1. FAST PATH — `self` (`process.env.SWITCHROOM_AGENT_NAME`). Set
|
|
16
|
+
* authoritatively by compose env (compose.ts) AND hoisted in start.sh
|
|
17
|
+
* before the gateway fork, so this is present in the overwhelming
|
|
18
|
+
* majority of runs.
|
|
19
|
+
* 2. DURABLE FALLBACK — when `self` is unset/empty, derive the identity
|
|
20
|
+
* from `agentDir` (the agent's own directory, itself derived from
|
|
21
|
+
* `TELEGRAM_STATE_DIR` = `<agentDir>/telegram`, which the gateway
|
|
22
|
+
* already requires to be present before it even starts the watcher).
|
|
23
|
+
* The basename of `agentDir` is `resolve(agents_dir, <name>)`'s leaf —
|
|
24
|
+
* i.e. this agent's OWN name. This can only ever resolve to THIS
|
|
25
|
+
* agent's identity, never another agent's, so it cannot mis-attribute:
|
|
26
|
+
* a wrong basename matches zero registry records (fail-closed), it
|
|
27
|
+
* never matches a DIFFERENT owner. Env is just the fast path; ownership
|
|
28
|
+
* resolves correctly from durable config when env is missing.
|
|
29
|
+
*
|
|
30
|
+
* Fail-CLOSED, deliberately, and never mis-attributing:
|
|
31
|
+
*
|
|
32
|
+
* - Owner match ⇒ include, realpath'd. Claude Code mints the project slug
|
|
33
|
+
* off the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
|
|
23
34
|
* `/private/tmp`) would otherwise derive a slug that misses the physical
|
|
24
35
|
* one; realpath best-effort, falling back to the raw path.
|
|
36
|
+
* - Ownerless registry records (`ownerAgent` undefined) are NEVER matched,
|
|
37
|
+
* even with identity set — a naive `undefined === undefined` would leak
|
|
38
|
+
* every other agent's ownerless worktree (the #1116 leak this exists to
|
|
39
|
+
* prevent).
|
|
40
|
+
* - A registry read failure ⇒ `[]` (best-effort; never disturb the base
|
|
41
|
+
* agentCwd watch).
|
|
42
|
+
* - BOTH env AND agentDir-derived identity unavailable ⇒ `[]` (same
|
|
43
|
+
* fail-closed contract as before this fix — we never guess) but escalate
|
|
44
|
+
* the log from the #2893 one-shot warn to a clear ERROR naming that
|
|
45
|
+
* identity resolution fully failed, so the lost live feed is diagnosable.
|
|
46
|
+
* Never throws, never mis-attributes.
|
|
25
47
|
*/
|
|
26
48
|
import { realpathSync } from "node:fs";
|
|
49
|
+
import { basename } from "node:path";
|
|
27
50
|
|
|
28
51
|
export interface WorktreeOwnershipRecord {
|
|
29
52
|
path: string;
|
|
@@ -31,22 +54,74 @@ export interface WorktreeOwnershipRecord {
|
|
|
31
54
|
}
|
|
32
55
|
|
|
33
56
|
export interface OwnedWorktreeCwdsOptions {
|
|
34
|
-
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME
|
|
57
|
+
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
|
|
35
58
|
self: string | undefined;
|
|
36
59
|
/** The host-global registry read (`listRecords` from src/worktree/registry). */
|
|
37
60
|
listRecords: () => WorktreeOwnershipRecord[];
|
|
61
|
+
/**
|
|
62
|
+
* Durable, non-env fallback source for identity: the agent's OWN directory
|
|
63
|
+
* (`resolveAgentDirFromEnv()` in the gateway). When `self` is unset/empty,
|
|
64
|
+
* the identity is derived as `basename(agentDir)`. Omit to disable the
|
|
65
|
+
* fallback (the pre-fix, env-only behaviour — used by the kill-switch).
|
|
66
|
+
*/
|
|
67
|
+
agentDir?: string | null;
|
|
38
68
|
/** Injectable for tests; defaults to `fs.realpathSync`. */
|
|
39
69
|
realpath?: (p: string) => string;
|
|
70
|
+
/**
|
|
71
|
+
* Injectable derivation of the agent name from `agentDir`. Defaults to
|
|
72
|
+
* `path.basename`. Returns "" when it cannot derive a usable name.
|
|
73
|
+
*/
|
|
74
|
+
deriveName?: (agentDir: string) => string;
|
|
75
|
+
/** Escalated-failure sink (both identity sources unavailable). */
|
|
76
|
+
log?: (msg: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One-shot guard so the escalated "identity fully unresolved" ERROR is emitted
|
|
80
|
+
// ONCE per process rather than every rescan tick (the provider is re-invoked on
|
|
81
|
+
// every tick). Mirrors the #2893 one-shot-warn ethos; exported reset for tests.
|
|
82
|
+
let identityEscalated = false;
|
|
83
|
+
export function __resetIdentityEscalationForTests(): void {
|
|
84
|
+
identityEscalated = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultDeriveName(agentDir: string): string {
|
|
88
|
+
if (!agentDir || agentDir.trim().length === 0) return "";
|
|
89
|
+
const leaf = basename(agentDir).trim();
|
|
90
|
+
return leaf;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
93
|
export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
|
|
43
|
-
|
|
44
|
-
|
|
94
|
+
// Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
|
|
95
|
+
let resolved: string = opts.self != null ? opts.self : "";
|
|
96
|
+
if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
|
|
97
|
+
const derive = opts.deriveName ?? defaultDeriveName;
|
|
98
|
+
resolved = derive(opts.agentDir) || "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (resolved === "") {
|
|
102
|
+
// Both env and durable config unavailable. Keep the historical
|
|
103
|
+
// fail-closed contract (return [] — never guess, never mis-attribute) but
|
|
104
|
+
// ESCALATE past the #2893 one-shot warn: name that identity resolution
|
|
105
|
+
// fully failed and the live worktree-worker feed is lost for this run.
|
|
106
|
+
if (!identityEscalated) {
|
|
107
|
+
identityEscalated = true;
|
|
108
|
+
opts.log?.(
|
|
109
|
+
"ERROR: worktree identity resolution FAILED — both " +
|
|
110
|
+
"SWITCHROOM_AGENT_NAME and the agentDir-derived fallback are " +
|
|
111
|
+
"unavailable. Worktree ownership cannot be attributed; a " +
|
|
112
|
+
"worktree-isolated background sub-agent will get NO live progress " +
|
|
113
|
+
"feed this run (its registry row is still reaped by the 1h safety " +
|
|
114
|
+
"net). This is a configuration fault, not a transient error.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
45
120
|
const rp = opts.realpath ?? realpathSync;
|
|
46
121
|
try {
|
|
47
122
|
return opts
|
|
48
123
|
.listRecords()
|
|
49
|
-
.filter((r) => r.ownerAgent ===
|
|
124
|
+
.filter((r) => r.ownerAgent === resolved)
|
|
50
125
|
.map((r) => {
|
|
51
126
|
try {
|
|
52
127
|
return rp(r.path);
|