switchroom 0.14.90 → 0.14.92
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 +1030 -56
- package/dist/auth-broker/index.js +50 -3
- package/dist/cli/notion-write-pretool.mjs +50 -3
- package/dist/cli/switchroom.js +306 -21
- package/dist/host-control/main.js +50 -3
- package/dist/vault/approvals/kernel-server.js +51 -4
- package/dist/vault/broker/server.js +51 -4
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +77 -0
- package/profiles/_base/start.sh.hbs +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +147 -15
- package/telegram-plugin/gateway/cron-session.ts +45 -0
- package/telegram-plugin/gateway/gateway.ts +116 -7
- package/telegram-plugin/gateway/subagent-status-surface.test.ts +118 -0
- package/telegram-plugin/gateway/subagent-status-surface.ts +69 -0
- package/telegram-plugin/tests/cron-session.test.ts +32 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap-cron session identity — docs/rfcs/cheap-cron-sessions.md §3.3.
|
|
3
|
+
*
|
|
4
|
+
* Rather than rekey the gateway's hardened single-bridge machinery
|
|
5
|
+
* (agentIndex / pendingInboundBuffer / handleRegister, each carrying
|
|
6
|
+
* subtle race fixes), a Tier-1 cron fire is routed to a SECOND bridge
|
|
7
|
+
* that registers under a DERIVED identity `<agent>-cron`. To the IPC
|
|
8
|
+
* layer it is "just another agent", so routing, buffering, disconnect,
|
|
9
|
+
* and heartbeat all work unchanged. The gateway gates its SINGLETON
|
|
10
|
+
* status machinery (shadow bridge-state, boot card, currentTurn /
|
|
11
|
+
* progress card / silence-poke) off the cron identity — which IS the
|
|
12
|
+
* §2.4 "cron session is status-silent" requirement, so it is one change,
|
|
13
|
+
* not two.
|
|
14
|
+
*
|
|
15
|
+
* The cron session's bridge sets SWITCHROOM_AGENT_NAME=`<agent>-cron`;
|
|
16
|
+
* the scheduler emits `meta.session='cron'` and the gateway derives the
|
|
17
|
+
* target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
|
|
21
|
+
export const CRON_IDENTITY_SUFFIX = "-cron";
|
|
22
|
+
|
|
23
|
+
/** Derive the cron-session bridge identity for an agent. */
|
|
24
|
+
export function cronIdentity(agent: string): string {
|
|
25
|
+
return `${agent}${CRON_IDENTITY_SUFFIX}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True iff `name` is a cron-session bridge identity (not a main agent bridge). */
|
|
29
|
+
export function isCronIdentity(name: string | null | undefined): boolean {
|
|
30
|
+
return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The real agent name behind a (possibly cron) identity. */
|
|
34
|
+
export function baseAgent(name: string): string {
|
|
35
|
+
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the IPC routing target for an inject_inbound. When the fire
|
|
40
|
+
* carries `meta.session='cron'` it goes to the derived cron bridge; every
|
|
41
|
+
* other fire (and all of today's callers) goes to the agent unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveInjectTarget(agentName: string, meta: Record<string, string> | undefined): string {
|
|
44
|
+
return meta?.session === "cron" ? cronIdentity(agentName) : agentName;
|
|
45
|
+
}
|
|
@@ -287,6 +287,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
287
287
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
288
288
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
289
289
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
290
|
+
import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
|
|
290
291
|
import {
|
|
291
292
|
ObligationLedger,
|
|
292
293
|
buildObligationRepresentInbound,
|
|
@@ -471,6 +472,10 @@ import {
|
|
|
471
472
|
} from './resume-inbound-builder.js'
|
|
472
473
|
import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
|
|
473
474
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
475
|
+
import {
|
|
476
|
+
resolveSubagentStatusSurface,
|
|
477
|
+
isOrphanSubagentStatusEnabled,
|
|
478
|
+
} from './subagent-status-surface.js'
|
|
474
479
|
import { formatIdleFooter } from '../idle-footer.js'
|
|
475
480
|
import { resolveCallingSubagent } from './resolve-calling-subagent.js'
|
|
476
481
|
|
|
@@ -5470,6 +5475,19 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5470
5475
|
|
|
5471
5476
|
onClientRegistered(client: IpcClient) {
|
|
5472
5477
|
process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
|
|
5478
|
+
// Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
|
|
5479
|
+
// session. It is STATUS-SILENT — it must NOT drive the gateway's
|
|
5480
|
+
// singleton machinery (shadow bridge-state, warmup, boot card, which all
|
|
5481
|
+
// track the MAIN agent's liveness). Drain any buffered cron fire to it
|
|
5482
|
+
// (so a fire that triggered a lazy spawn lands), then return early.
|
|
5483
|
+
if (isCronIdentity(client.agentName)) {
|
|
5484
|
+
client.send({ type: 'status', status: 'agent_connected' })
|
|
5485
|
+
const pending = pendingInboundBuffer.drain(client.agentName ?? '')
|
|
5486
|
+
for (const m of pending) {
|
|
5487
|
+
try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
|
|
5488
|
+
}
|
|
5489
|
+
return
|
|
5490
|
+
}
|
|
5473
5491
|
// Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
|
|
5474
5492
|
// (with an agent name). Anonymous IPC clients (recall.py, mcp
|
|
5475
5493
|
// handshakes, etc.) connect briefly without a name and would
|
|
@@ -5648,6 +5666,17 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5648
5666
|
},
|
|
5649
5667
|
|
|
5650
5668
|
onClientDisconnected(client: IpcClient) {
|
|
5669
|
+
// Cheap-cron §2.4: a `<agent>-cron` bridge is the status-silent cron
|
|
5670
|
+
// session. Its disconnect (e.g. B3 lazy idle-teardown) must NOT touch
|
|
5671
|
+
// the MAIN agent's singleton state — emitting bridgeDown (the shadow
|
|
5672
|
+
// state is unkeyed) or flushOnAgentDisconnect (flushes the main agent's
|
|
5673
|
+
// active reactions / disposes its progress driver mid-turn) would be the
|
|
5674
|
+
// "premature 👍" bug for the main session. Symmetric to the cron gate in
|
|
5675
|
+
// onClientRegistered / onSessionEvent / onPtyPartial.
|
|
5676
|
+
if (isCronIdentity(client.agentName)) {
|
|
5677
|
+
process.stderr.write(`telegram gateway: cron-session bridge disconnected — agent=${client.agentName}\n`)
|
|
5678
|
+
return
|
|
5679
|
+
}
|
|
5651
5680
|
// ONLY log "bridge disconnected" + emit bridgeDown for the REAL
|
|
5652
5681
|
// bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
|
|
5653
5682
|
// clients — e.g. recall.py's one-shot legacy update_placeholder
|
|
@@ -5718,7 +5747,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5718
5747
|
}
|
|
5719
5748
|
},
|
|
5720
5749
|
|
|
5721
|
-
onSessionEvent(
|
|
5750
|
+
onSessionEvent(client: IpcClient, msg: SessionEventForward) {
|
|
5751
|
+
// Cheap-cron §2.4: the cron session is status-silent — its session
|
|
5752
|
+
// events must not drive the main agent's progress card, transcript
|
|
5753
|
+
// tail, currentTurn, or silence-poke. Its reply still flows (onToolCall
|
|
5754
|
+
// is not gated). currentTurn was never set for a cron fire (onInjectInbound
|
|
5755
|
+
// skips markClaudeBusy), so the card machinery has nothing to attach to.
|
|
5756
|
+
if (isCronIdentity(client.agentName)) return
|
|
5722
5757
|
// Track the session-tail's attached file for the proactive-
|
|
5723
5758
|
// compaction occupancy read (see maybeProactiveCompact).
|
|
5724
5759
|
if (msg.activeFile) lastSessionActiveFile = msg.activeFile
|
|
@@ -5950,7 +5985,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5950
5985
|
* `handlePtyPartial` does its own buffering for the
|
|
5951
5986
|
* partial-before-enqueue race.
|
|
5952
5987
|
*/
|
|
5953
|
-
onPtyPartial(
|
|
5988
|
+
onPtyPartial(client: IpcClient, msg: PtyPartialForward) {
|
|
5989
|
+
// Cheap-cron §2.4: cron session is status-silent — no visible reply
|
|
5990
|
+
// stream from its PTY tail (it would edit a card the main session owns).
|
|
5991
|
+
if (isCronIdentity(client.agentName)) return
|
|
5954
5992
|
handlePtyPartial(msg.text)
|
|
5955
5993
|
},
|
|
5956
5994
|
|
|
@@ -6287,16 +6325,28 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6287
6325
|
const source = typeof msg.inbound.meta?.source === 'string'
|
|
6288
6326
|
? msg.inbound.meta.source
|
|
6289
6327
|
: 'unknown'
|
|
6290
|
-
|
|
6291
|
-
|
|
6328
|
+
// Cheap-cron (docs/rfcs/cheap-cron-sessions.md §3.3): a Tier-1 fire
|
|
6329
|
+
// carries meta.session='cron' → route to the derived `<agent>-cron`
|
|
6330
|
+
// bridge (a 2nd interactive Sonnet session in the same container).
|
|
6331
|
+
// Every other fire (and all of today's callers) routes to the agent
|
|
6332
|
+
// unchanged. Route+buffer share the same target so a fire that lands
|
|
6333
|
+
// mid cron-session-spawn buffers under the cron identity and drains to
|
|
6334
|
+
// it on register.
|
|
6335
|
+
const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
|
|
6336
|
+
const toCron = target !== msg.agentName
|
|
6337
|
+
const delivered = ipcServer.sendToAgent(target, msg.inbound)
|
|
6338
|
+
// Status-silent (§2.4): a cron fire must NOT set the MAIN agent's
|
|
6339
|
+
// currentTurn (progress card / silence-poke). The cron session is
|
|
6340
|
+
// fire-and-forget; its reply is its only Telegram surface.
|
|
6341
|
+
if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
|
|
6292
6342
|
process.stderr.write(
|
|
6293
|
-
`telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
|
|
6343
|
+
`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
|
|
6294
6344
|
)
|
|
6295
6345
|
// #1150: same buffer-on-failure pattern as vault_grant_approved.
|
|
6296
6346
|
// Cron fires use this path too — if a cron-driven wake-up lands
|
|
6297
6347
|
// mid bridge-reconnect, buffer it for the next register.
|
|
6298
6348
|
if (!delivered) {
|
|
6299
|
-
pendingInboundBuffer.push(
|
|
6349
|
+
pendingInboundBuffer.push(target, msg.inbound)
|
|
6300
6350
|
}
|
|
6301
6351
|
},
|
|
6302
6352
|
|
|
@@ -20410,6 +20460,11 @@ void (async () => {
|
|
|
20410
20460
|
// compose draft, so no answer-stream contention). The kill-switch
|
|
20411
20461
|
// disables only the nesting; the parent's own feed is unaffected.
|
|
20412
20462
|
const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'
|
|
20463
|
+
// Orphaned-foreground status (2026-06-09): a FOREGROUND sub-agent
|
|
20464
|
+
// with no live parent turn to nest into (dispatched outside a turn,
|
|
20465
|
+
// or the turn ended while it kept running — extended autonomous
|
|
20466
|
+
// work) is surfaced via the worker feed instead of vanishing.
|
|
20467
|
+
const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
|
|
20413
20468
|
const workerActivityFeed = createWorkerActivityFeed({
|
|
20414
20469
|
bot: {
|
|
20415
20470
|
sendMessage: async (cid, text, sendOpts) => {
|
|
@@ -20609,6 +20664,29 @@ void (async () => {
|
|
|
20609
20664
|
}
|
|
20610
20665
|
}
|
|
20611
20666
|
}
|
|
20667
|
+
return
|
|
20668
|
+
}
|
|
20669
|
+
// Not nested → an orphaned foreground sub-agent that was
|
|
20670
|
+
// surfaced via the worker feed (no live turn to nest into):
|
|
20671
|
+
// finalize its message (no-op if none was posted). A
|
|
20672
|
+
// foreground result returns inline as the Task tool result, so
|
|
20673
|
+
// there is no handback to deliver — return after.
|
|
20674
|
+
if (
|
|
20675
|
+
resolveSubagentStatusSurface({
|
|
20676
|
+
isBackground: false,
|
|
20677
|
+
liveTurnPresent: false,
|
|
20678
|
+
workerFeedEnabled,
|
|
20679
|
+
orphanStatusEnabled,
|
|
20680
|
+
}) === 'worker-feed'
|
|
20681
|
+
) {
|
|
20682
|
+
void workerActivityFeed.finish(agentId, {
|
|
20683
|
+
description: dispatch.feedDescription,
|
|
20684
|
+
lastTool: null,
|
|
20685
|
+
toolCount,
|
|
20686
|
+
latestSummary: resultText,
|
|
20687
|
+
elapsedMs: durationMs,
|
|
20688
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
20689
|
+
})
|
|
20612
20690
|
}
|
|
20613
20691
|
return
|
|
20614
20692
|
}
|
|
@@ -20738,8 +20816,39 @@ void (async () => {
|
|
|
20738
20816
|
// activity draft rather than a separate worker message. Pure
|
|
20739
20817
|
// jsonl-tail → render (no model call), inside the
|
|
20740
20818
|
// subscription-honest boundary.
|
|
20819
|
+
//
|
|
20820
|
+
// But a foreground sub-agent with NO live turn to nest into
|
|
20821
|
+
// (dispatched outside a turn, or the turn ended while it kept
|
|
20822
|
+
// running — extended autonomous work) has nowhere to nest, and
|
|
20823
|
+
// pre-fix it silently returned here → invisible. Route through
|
|
20824
|
+
// the proven decision: an orphaned foreground sub-agent goes to
|
|
20825
|
+
// the worker feed (owner-DM fallback), not into the void.
|
|
20826
|
+
const surface = resolveSubagentStatusSurface({
|
|
20827
|
+
isBackground: false,
|
|
20828
|
+
liveTurnPresent: currentTurn != null,
|
|
20829
|
+
workerFeedEnabled,
|
|
20830
|
+
orphanStatusEnabled,
|
|
20831
|
+
})
|
|
20832
|
+
if (surface === 'worker-feed') {
|
|
20833
|
+
const origin = resolveSubagentOriginChat(agentId)
|
|
20834
|
+
void workerActivityFeed.update(
|
|
20835
|
+
agentId,
|
|
20836
|
+
origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
|
|
20837
|
+
{
|
|
20838
|
+
description: dispatch.feedDescription,
|
|
20839
|
+
lastTool,
|
|
20840
|
+
toolCount,
|
|
20841
|
+
latestSummary,
|
|
20842
|
+
elapsedMs,
|
|
20843
|
+
state: 'running',
|
|
20844
|
+
},
|
|
20845
|
+
origin?.threadId,
|
|
20846
|
+
)
|
|
20847
|
+
return
|
|
20848
|
+
}
|
|
20849
|
+
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
20741
20850
|
const turn = currentTurn
|
|
20742
|
-
if (turn == null) return
|
|
20851
|
+
if (turn == null) return // defensive: 'nest' implies a live turn
|
|
20743
20852
|
// Render regardless of `replyCalled` — a foreground Task
|
|
20744
20853
|
// blocks the parent, so any reply seen while it runs is an
|
|
20745
20854
|
// interim ack, never the final answer. Gating on replyCalled
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
resolveSubagentStatusSurface,
|
|
4
|
+
isOrphanSubagentStatusEnabled,
|
|
5
|
+
type SubagentStatusSurface,
|
|
6
|
+
type SubagentStatusSurfaceInput,
|
|
7
|
+
} from './subagent-status-surface.js'
|
|
8
|
+
|
|
9
|
+
// ── Human-readable map ──────────────────────────────────────────────────────
|
|
10
|
+
describe('resolveSubagentStatusSurface', () => {
|
|
11
|
+
const base: SubagentStatusSurfaceInput = {
|
|
12
|
+
isBackground: false,
|
|
13
|
+
liveTurnPresent: true,
|
|
14
|
+
workerFeedEnabled: true,
|
|
15
|
+
orphanStatusEnabled: true,
|
|
16
|
+
}
|
|
17
|
+
it('foreground + live turn → nest (unchanged default)', () => {
|
|
18
|
+
expect(resolveSubagentStatusSurface(base)).toBe('nest')
|
|
19
|
+
})
|
|
20
|
+
it('THE fix: orphaned foreground (no live turn) → worker-feed', () => {
|
|
21
|
+
expect(resolveSubagentStatusSurface({ ...base, liveTurnPresent: false })).toBe('worker-feed')
|
|
22
|
+
})
|
|
23
|
+
it('kill switch: orphaned foreground with orphanStatus OFF → skip (pre-fix invisible)', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, orphanStatusEnabled: false }),
|
|
26
|
+
).toBe('skip')
|
|
27
|
+
})
|
|
28
|
+
it('orphaned foreground but feed OFF → skip (nothing to surface through)', () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, workerFeedEnabled: false }),
|
|
31
|
+
).toBe('skip')
|
|
32
|
+
})
|
|
33
|
+
it('background + feed on → worker-feed', () => {
|
|
34
|
+
expect(resolveSubagentStatusSurface({ ...base, isBackground: true, liveTurnPresent: false })).toBe('worker-feed')
|
|
35
|
+
})
|
|
36
|
+
it('background + feed off → legacy-relay', () => {
|
|
37
|
+
expect(
|
|
38
|
+
resolveSubagentStatusSurface({ ...base, isBackground: true, workerFeedEnabled: false }),
|
|
39
|
+
).toBe('legacy-relay')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('isOrphanSubagentStatusEnabled — default ON, =0 kill switch', () => {
|
|
44
|
+
it('undefined / "1" / "" → on; "0" → off', () => {
|
|
45
|
+
expect(isOrphanSubagentStatusEnabled(undefined)).toBe(true)
|
|
46
|
+
expect(isOrphanSubagentStatusEnabled('1')).toBe(true)
|
|
47
|
+
expect(isOrphanSubagentStatusEnabled('')).toBe(true)
|
|
48
|
+
expect(isOrphanSubagentStatusEnabled('0')).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
|
|
53
|
+
// 4 booleans = 16 reachable inputs. Enumerate all, assert totality, determinism,
|
|
54
|
+
// the documented table (independent spec), and the load-bearing invariants.
|
|
55
|
+
// (operator standard feedback_prove_finite_fsm_not_sample.)
|
|
56
|
+
function allInputs(): SubagentStatusSurfaceInput[] {
|
|
57
|
+
const rows: SubagentStatusSurfaceInput[] = []
|
|
58
|
+
for (const isBackground of [false, true])
|
|
59
|
+
for (const liveTurnPresent of [false, true])
|
|
60
|
+
for (const workerFeedEnabled of [false, true])
|
|
61
|
+
for (const orphanStatusEnabled of [false, true])
|
|
62
|
+
rows.push({ isBackground, liveTurnPresent, workerFeedEnabled, orphanStatusEnabled })
|
|
63
|
+
return rows
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Independent spec encoding (kept separate from the impl).
|
|
67
|
+
function spec(i: SubagentStatusSurfaceInput): SubagentStatusSurface {
|
|
68
|
+
if (i.isBackground) return i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
|
|
69
|
+
if (i.liveTurnPresent) return 'nest'
|
|
70
|
+
if (!i.orphanStatusEnabled) return 'skip'
|
|
71
|
+
return i.workerFeedEnabled ? 'worker-feed' : 'skip'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('resolveSubagentStatusSurface — total enumeration (16 inputs)', () => {
|
|
75
|
+
const ROWS = allInputs()
|
|
76
|
+
|
|
77
|
+
it('exactly 16 reachable inputs (2^4)', () => {
|
|
78
|
+
expect(ROWS.length).toBe(16)
|
|
79
|
+
})
|
|
80
|
+
it('TOTAL + DETERMINISTIC: every input returns one of the four surfaces, idempotently', () => {
|
|
81
|
+
const surfaces = new Set<SubagentStatusSurface>(['nest', 'worker-feed', 'legacy-relay', 'skip'])
|
|
82
|
+
for (const i of ROWS) {
|
|
83
|
+
const a = resolveSubagentStatusSurface(i)
|
|
84
|
+
expect(surfaces.has(a)).toBe(true)
|
|
85
|
+
expect(resolveSubagentStatusSurface({ ...i })).toBe(a)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
it('PRECEDENCE: matches the documented spec on all 16 inputs', () => {
|
|
89
|
+
for (const i of ROWS) expect(resolveSubagentStatusSurface(i)).toBe(spec(i))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('INV-ORPHAN-VISIBLE: an orphaned foreground sub-agent is NEVER skip when orphanStatus + feed are on', () => {
|
|
93
|
+
for (const i of ROWS) {
|
|
94
|
+
if (!i.isBackground && !i.liveTurnPresent && i.orphanStatusEnabled && i.workerFeedEnabled) {
|
|
95
|
+
expect(resolveSubagentStatusSurface(i)).toBe('worker-feed')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
it('INV-KILL-SWITCH: orphanStatus OFF ⇒ an orphaned foreground sub-agent is exactly the pre-fix behaviour (skip)', () => {
|
|
100
|
+
for (const i of ROWS) {
|
|
101
|
+
if (!i.isBackground && !i.liveTurnPresent && !i.orphanStatusEnabled) {
|
|
102
|
+
expect(resolveSubagentStatusSurface(i)).toBe('skip')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
it('INV-NEST-UNCHANGED: a foreground sub-agent with a live turn is ALWAYS nest, independent of the other flags', () => {
|
|
107
|
+
for (const i of ROWS) {
|
|
108
|
+
if (!i.isBackground && i.liveTurnPresent) expect(resolveSubagentStatusSurface(i)).toBe('nest')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
it('INV-BACKGROUND-UNCHANGED: background routing depends ONLY on the feed flag, never on liveTurn/orphanStatus', () => {
|
|
112
|
+
for (const i of ROWS) {
|
|
113
|
+
if (i.isBackground) {
|
|
114
|
+
expect(resolveSubagentStatusSurface(i)).toBe(i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where does a sub-agent's live status go?
|
|
3
|
+
*
|
|
4
|
+
* A sub-agent's progress is surfaced on one of four "surfaces". This pure
|
|
5
|
+
* decision picks which, so the routing is provable by total enumeration rather
|
|
6
|
+
* than buried in the gateway's imperative branches.
|
|
7
|
+
*
|
|
8
|
+
* - `nest` — a FOREGROUND sub-agent running inside a LIVE parent turn:
|
|
9
|
+
* its narrative nests under the parent's activity draft
|
|
10
|
+
* (the progress card). The default, unchanged.
|
|
11
|
+
* - `worker-feed` — a BACKGROUND worker (the `🛠 Worker` edit-in-place
|
|
12
|
+
* message), OR a FOREGROUND sub-agent that has NO live
|
|
13
|
+
* parent turn to nest into (dispatched outside a turn, or
|
|
14
|
+
* the turn ended while it kept running). The latter is the
|
|
15
|
+
* 2026-06-09 fix: extended autonomous work was invisible
|
|
16
|
+
* because foreground status was turn-scoped and a sub-agent
|
|
17
|
+
* with no turn silently returned. It now reuses the worker
|
|
18
|
+
* feed (with the same owner-DM fallback background workers
|
|
19
|
+
* already use), so post-turn work is always visible.
|
|
20
|
+
* - `legacy-relay` — a BACKGROUND worker when the worker feed is OFF: fall
|
|
21
|
+
* back to the legacy "still working" injected-inbound relay.
|
|
22
|
+
* - `skip` — nothing to surface (kill-switch off for an orphaned
|
|
23
|
+
* foreground, or no feed to surface it through).
|
|
24
|
+
*
|
|
25
|
+
* Determinism: the input space is 4 booleans = 16 rows, enumerated and proven
|
|
26
|
+
* in subagent-status-surface.test.ts (operator standard
|
|
27
|
+
* feedback_prove_finite_fsm_not_sample). The load-bearing invariant: an
|
|
28
|
+
* orphaned foreground sub-agent (no live turn) is `worker-feed`, never `skip`,
|
|
29
|
+
* whenever the orphan-status flag and the feed are both on.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type SubagentStatusSurface = 'nest' | 'worker-feed' | 'legacy-relay' | 'skip'
|
|
33
|
+
|
|
34
|
+
export interface SubagentStatusSurfaceInput {
|
|
35
|
+
/** run_in_background dispatch (registry `subagents.background`). */
|
|
36
|
+
isBackground: boolean
|
|
37
|
+
/**
|
|
38
|
+
* A LIVE parent turn exists to nest this (foreground) sub-agent into.
|
|
39
|
+
* onProgress: `currentTurn != null`. onFinish: `currentTurn != null && it was
|
|
40
|
+
* actually nested` (so a foreground sub-agent that was surfaced via the worker
|
|
41
|
+
* feed — never nested — finalizes through the feed, not a turn collapse).
|
|
42
|
+
* Ignored for background sub-agents.
|
|
43
|
+
*/
|
|
44
|
+
liveTurnPresent: boolean
|
|
45
|
+
/** SWITCHROOM_WORKER_ACTIVITY_FEED on. */
|
|
46
|
+
workerFeedEnabled: boolean
|
|
47
|
+
/** SWITCHROOM_ORPHAN_SUBAGENT_STATUS on — surface no-parent-turn foreground
|
|
48
|
+
* sub-agents via the worker feed. Off = pre-fix behaviour (invisible). */
|
|
49
|
+
orphanStatusEnabled: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveSubagentStatusSurface(
|
|
53
|
+
input: SubagentStatusSurfaceInput,
|
|
54
|
+
): SubagentStatusSurface {
|
|
55
|
+
if (!input.isBackground) {
|
|
56
|
+
// Foreground sub-agent.
|
|
57
|
+
if (input.liveTurnPresent) return 'nest'
|
|
58
|
+
// Orphaned foreground: no live turn to nest into — the invisible case.
|
|
59
|
+
if (!input.orphanStatusEnabled) return 'skip' // kill switch: pre-fix behaviour
|
|
60
|
+
return input.workerFeedEnabled ? 'worker-feed' : 'skip' // surfacing needs the feed
|
|
61
|
+
}
|
|
62
|
+
// Background worker.
|
|
63
|
+
return input.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** SWITCHROOM_ORPHAN_SUBAGENT_STATUS — default ON; `=0` restores pre-fix (invisible) behaviour. */
|
|
67
|
+
export function isOrphanSubagentStatusEnabled(envVal: string | undefined): boolean {
|
|
68
|
+
return envVal !== '0'
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
CRON_IDENTITY_SUFFIX,
|
|
4
|
+
baseAgent,
|
|
5
|
+
cronIdentity,
|
|
6
|
+
isCronIdentity,
|
|
7
|
+
resolveInjectTarget,
|
|
8
|
+
} from '../gateway/cron-session.js'
|
|
9
|
+
|
|
10
|
+
describe('cron-session identity helpers', () => {
|
|
11
|
+
it('derives and detects the cron identity', () => {
|
|
12
|
+
expect(cronIdentity('clerk')).toBe(`clerk${CRON_IDENTITY_SUFFIX}`)
|
|
13
|
+
expect(isCronIdentity('clerk-cron')).toBe(true)
|
|
14
|
+
expect(isCronIdentity('clerk')).toBe(false)
|
|
15
|
+
expect(isCronIdentity(null)).toBe(false)
|
|
16
|
+
expect(isCronIdentity(undefined)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('round-trips base agent', () => {
|
|
20
|
+
expect(baseAgent(cronIdentity('marko'))).toBe('marko')
|
|
21
|
+
expect(baseAgent('marko')).toBe('marko')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('resolveInjectTarget routes only meta.session=cron to the cron bridge', () => {
|
|
25
|
+
expect(resolveInjectTarget('clerk', { session: 'cron', source: 'cron' })).toBe('clerk-cron')
|
|
26
|
+
expect(resolveInjectTarget('clerk', { session: 'main', source: 'cron' })).toBe('clerk')
|
|
27
|
+
expect(resolveInjectTarget('clerk', { source: 'cron' })).toBe('clerk')
|
|
28
|
+
expect(resolveInjectTarget('clerk', undefined)).toBe('clerk')
|
|
29
|
+
// back-compat: every legacy caller (no session) is unchanged.
|
|
30
|
+
expect(resolveInjectTarget('clerk', { source: 'telegram' })).toBe('clerk')
|
|
31
|
+
})
|
|
32
|
+
})
|