switchroom 0.14.18 β 0.14.19
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/cli/switchroom.js
CHANGED
|
@@ -49416,8 +49416,8 @@ var {
|
|
|
49416
49416
|
} = import__.default;
|
|
49417
49417
|
|
|
49418
49418
|
// src/build-info.ts
|
|
49419
|
-
var VERSION = "0.14.
|
|
49420
|
-
var COMMIT_SHA = "
|
|
49419
|
+
var VERSION = "0.14.19";
|
|
49420
|
+
var COMMIT_SHA = "21863276";
|
|
49421
49421
|
|
|
49422
49422
|
// src/cli/agent.ts
|
|
49423
49423
|
init_source();
|
|
@@ -50216,6 +50216,37 @@ a flood. Going quiet mid-work is fine \u2014 going quiet *instead* of
|
|
|
50216
50216
|
acknowledging, or *instead* of an update at a real milestone, is the
|
|
50217
50217
|
black box this exists to prevent.
|
|
50218
50218
|
|
|
50219
|
+
### Formatting \u2014 make it scannable
|
|
50220
|
+
|
|
50221
|
+
\`reply\` and \`stream_reply\` render Markdown as Telegram HTML for you, so
|
|
50222
|
+
\`**bold**\` becomes bold and backtick-wrapped text becomes monospace. Use it.
|
|
50223
|
+
|
|
50224
|
+
- **A one- or two-line conversational reply needs almost no markup.** Keep
|
|
50225
|
+
bold for the single fact that matters, never for decoration. "on it, pulling
|
|
50226
|
+
the logs now" is already perfect.
|
|
50227
|
+
- **A multi-section message needs visual hierarchy or it reads as a flat
|
|
50228
|
+
wall.** When you group several blocks \u2014 a status update, a "where things
|
|
50229
|
+
stand", a summary with distinct buckets, or **the message you post before
|
|
50230
|
+
kicking off a sub-agent or worker** \u2014 give each section a **bold label on
|
|
50231
|
+
its own line** and separate sections with **one blank line**. A row of
|
|
50232
|
+
emoji and bullets at equal weight with no spacing is the plain-text dump to
|
|
50233
|
+
avoid; bold labels + blank lines are what let the eye find the structure.
|
|
50234
|
+
Example shape:
|
|
50235
|
+
|
|
50236
|
+
**Dispatching**
|
|
50237
|
+
Kicking off a worker to crawl the changelog.
|
|
50238
|
+
|
|
50239
|
+
**What it'll do**
|
|
50240
|
+
\u2022 pull every entry since v0.14
|
|
50241
|
+
\u2022 flag anything user-facing
|
|
50242
|
+
|
|
50243
|
+
**Back in** ~2 min with a synthesized summary.
|
|
50244
|
+
- Bullets stay one level deep \u2014 Telegram flattens nested lists awkwardly. Use
|
|
50245
|
+
backtick-wrapped \`inline code\` for filenames, commands, and identifiers.
|
|
50246
|
+
- Don't use Markdown headings (\`#\` / \`##\`) in a reply \u2014 bold the label
|
|
50247
|
+
instead (\`**Blockers**\`, not \`## Blockers\`). Keep lines short; long
|
|
50248
|
+
unwrapped lines are hard to read on a phone.
|
|
50249
|
+
|
|
50219
50250
|
Every turn that answers a user message ends with a user-visible
|
|
50220
50251
|
\`reply\` (or \`stream_reply\` done=true) \u2014 Telegram is all the user
|
|
50221
50252
|
sees; your terminal output never reaches them.`;
|
package/package.json
CHANGED
|
@@ -31645,6 +31645,7 @@ var REACTION_VARIANTS = {
|
|
|
31645
31645
|
coding: ["\uD83D\uDC68\u200d\uD83D\uDCBB", "\u270d", "\u26a1"],
|
|
31646
31646
|
web: ["\u26a1", "\uD83E\uDD14", "\uD83D\uDC4C"],
|
|
31647
31647
|
compacting: ["\u270d", "\uD83E\uDD14", "\uD83D\uDC40"],
|
|
31648
|
+
awaiting: ["\uD83D\uDE4F", "\uD83E\uDD14", "\uD83D\uDC40"],
|
|
31648
31649
|
done: ["\uD83D\uDC4D", "\uD83D\uDCAF", "\uD83C\uDF89"],
|
|
31649
31650
|
error: ["\uD83D\uDE31", "\uD83D\uDE28", "\uD83E\uDD2F"],
|
|
31650
31651
|
stallSoft: ["\uD83E\uDD71", "\uD83D\uDE34", "\uD83E\uDD14"],
|
|
@@ -31697,6 +31698,12 @@ class StatusReactionController {
|
|
|
31697
31698
|
setCompacting() {
|
|
31698
31699
|
this.scheduleState("compacting");
|
|
31699
31700
|
}
|
|
31701
|
+
setAwaiting() {
|
|
31702
|
+
if (this.finished)
|
|
31703
|
+
return;
|
|
31704
|
+
this.scheduleState("awaiting", { immediate: true, skipStallReset: true });
|
|
31705
|
+
this.clearStallTimers();
|
|
31706
|
+
}
|
|
31700
31707
|
setError() {
|
|
31701
31708
|
this.scheduleState("error");
|
|
31702
31709
|
}
|
|
@@ -51237,10 +51244,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51237
51244
|
}
|
|
51238
51245
|
|
|
51239
51246
|
// ../src/build-info.ts
|
|
51240
|
-
var VERSION = "0.14.
|
|
51241
|
-
var COMMIT_SHA = "
|
|
51242
|
-
var COMMIT_DATE = "2026-05-
|
|
51243
|
-
var LATEST_PR =
|
|
51247
|
+
var VERSION = "0.14.19";
|
|
51248
|
+
var COMMIT_SHA = "21863276";
|
|
51249
|
+
var COMMIT_DATE = "2026-05-31T00:15:08Z";
|
|
51250
|
+
var LATEST_PR = 2013;
|
|
51244
51251
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51245
51252
|
|
|
51246
51253
|
// gateway/boot-version.ts
|
|
@@ -52458,6 +52465,12 @@ function countRunningWorkers() {
|
|
|
52458
52465
|
}
|
|
52459
52466
|
return n;
|
|
52460
52467
|
}
|
|
52468
|
+
function resumeReactionAfterVerdict() {
|
|
52469
|
+
const turn = currentTurn;
|
|
52470
|
+
if (turn == null)
|
|
52471
|
+
return;
|
|
52472
|
+
activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
|
|
52473
|
+
}
|
|
52461
52474
|
function resolveThreadId(chat_id, explicit) {
|
|
52462
52475
|
if (explicit != null)
|
|
52463
52476
|
return Number(explicit);
|
|
@@ -52891,6 +52904,7 @@ var pendingStateReaper = setInterval(() => {
|
|
|
52891
52904
|
for (const [k, v] of pendingPermissions) {
|
|
52892
52905
|
if (now - v.startedAt > PERMISSION_TTL_MS) {
|
|
52893
52906
|
dispatchPermissionVerdict({ type: "permission", requestId: k, behavior: "deny" });
|
|
52907
|
+
resumeReactionAfterVerdict();
|
|
52894
52908
|
process.stderr.write(`telegram gateway: permission TTL expired \u2014 auto-deny request=${k} tool=${v.tool_name} (no operator response in ${Math.round(PERMISSION_TTL_MS / 60000)}m)
|
|
52895
52909
|
`);
|
|
52896
52910
|
pendingPermissions.delete(k);
|
|
@@ -53532,6 +53546,9 @@ var ipcServer = createIpcServer({
|
|
|
53532
53546
|
`);
|
|
53533
53547
|
});
|
|
53534
53548
|
}
|
|
53549
|
+
if (activeTurn != null) {
|
|
53550
|
+
activeStatusReactions.get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))?.setAwaiting();
|
|
53551
|
+
}
|
|
53535
53552
|
},
|
|
53536
53553
|
onHeartbeat(_client, _msg) {},
|
|
53537
53554
|
onScheduleRestart(client3, msg) {
|
|
@@ -56283,6 +56300,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
|
|
|
56283
56300
|
requestId: request_id,
|
|
56284
56301
|
behavior
|
|
56285
56302
|
});
|
|
56303
|
+
resumeReactionAfterVerdict();
|
|
56286
56304
|
if (msgId != null) {
|
|
56287
56305
|
const emoji = behavior === "allow" ? "\u2705" : "\u274C";
|
|
56288
56306
|
bot.api.setMessageReaction(chat_id, msgId, [
|
|
@@ -57988,6 +58006,7 @@ async function handlePermissionSlash(ctx, behavior) {
|
|
|
57988
58006
|
return;
|
|
57989
58007
|
}
|
|
57990
58008
|
dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior });
|
|
58009
|
+
resumeReactionAfterVerdict();
|
|
57991
58010
|
pendingPermissions.delete(request_id);
|
|
57992
58011
|
process.stderr.write(`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}
|
|
57993
58012
|
`);
|
|
@@ -60284,6 +60303,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
60284
60303
|
behavior: "allow",
|
|
60285
60304
|
rule: chosen.rule
|
|
60286
60305
|
});
|
|
60306
|
+
resumeReactionAfterVerdict();
|
|
60287
60307
|
let durable = false;
|
|
60288
60308
|
let legacy = false;
|
|
60289
60309
|
let failReason = "";
|
|
@@ -60385,7 +60405,9 @@ ${editLabel}` : editLabel,
|
|
|
60385
60405
|
return;
|
|
60386
60406
|
}
|
|
60387
60407
|
pendingPermissions.delete(request_id);
|
|
60388
|
-
const
|
|
60408
|
+
const resumeAgent = process.env.SWITCHROOM_AGENT_NAME;
|
|
60409
|
+
const resumeBeat = resumeAgent ? `\u25B6\uFE0F ${escapeHtmlForTg(resumeAgent)} resuming\u2026` : "\u25B6\uFE0F resuming\u2026";
|
|
60410
|
+
const label = `${behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied"} \xB7 ${resumeBeat}`;
|
|
60389
60411
|
const msg = ctx.callbackQuery?.message;
|
|
60390
60412
|
const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
|
|
60391
60413
|
await finalizeCallback(ctx, {
|
|
@@ -60400,6 +60422,7 @@ ${label}` : label,
|
|
|
60400
60422
|
requestId: request_id,
|
|
60401
60423
|
behavior
|
|
60402
60424
|
});
|
|
60425
|
+
resumeReactionAfterVerdict();
|
|
60403
60426
|
}
|
|
60404
60427
|
});
|
|
60405
60428
|
});
|
|
@@ -1954,6 +1954,24 @@ function paintStatusReactionError(chatId: string, threadId: number | undefined):
|
|
|
1954
1954
|
ctrl.setError()
|
|
1955
1955
|
}
|
|
1956
1956
|
|
|
1957
|
+
/**
|
|
1958
|
+
* Flip the current turn's status reaction off π (awaiting-approval) back
|
|
1959
|
+
* to a working glyph once a permission verdict has been dispatched. The
|
|
1960
|
+
* turn was suspended *inside* the bridge's permission call, so `currentTurn`
|
|
1961
|
+
* still points at it; the verdict un-parks claude and it resumes the SAME
|
|
1962
|
+
* turn. `setThinking()` re-arms the stall watchdog that `setAwaiting()`
|
|
1963
|
+
* suspended, so a genuine post-approval hang still promotes to π₯±/π¨, and
|
|
1964
|
+
* it is replaced by the real tool glyph (β/β‘) as soon as the resumed turn
|
|
1965
|
+
* fires its next PreToolUse. Non-terminal β π still waits for `turn_end`.
|
|
1966
|
+
*/
|
|
1967
|
+
function resumeReactionAfterVerdict(): void {
|
|
1968
|
+
const turn = currentTurn
|
|
1969
|
+
if (turn == null) return
|
|
1970
|
+
activeStatusReactions
|
|
1971
|
+
.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
1972
|
+
?.setThinking()
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1957
1975
|
function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
|
|
1958
1976
|
if (explicit != null) return Number(explicit)
|
|
1959
1977
|
return chatThreadMap.get(chat_id)
|
|
@@ -2876,6 +2894,9 @@ const pendingStateReaper = setInterval(() => {
|
|
|
2876
2894
|
// dispatchPermissionVerdict so it's buffered+redelivered too if
|
|
2877
2895
|
// the bridge is also offline at sweep time.
|
|
2878
2896
|
dispatchPermissionVerdict({ type: 'permission', requestId: k, behavior: 'deny' })
|
|
2897
|
+
// The auto-deny un-parks the suspended turn β flip π β working so
|
|
2898
|
+
// it doesn't sit on the awaiting glyph (or stall) after the timeout.
|
|
2899
|
+
resumeReactionAfterVerdict()
|
|
2879
2900
|
process.stderr.write(
|
|
2880
2901
|
`telegram gateway: permission TTL expired β auto-deny request=${k} ` +
|
|
2881
2902
|
`tool=${v.tool_name} (no operator response in ` +
|
|
@@ -4227,6 +4248,16 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4227
4248
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
4228
4249
|
})
|
|
4229
4250
|
}
|
|
4251
|
+
// Park the turn's status reaction on π (awaiting your tap) and
|
|
4252
|
+
// suspend the stall watchdog β a turn blocked on the operator is not
|
|
4253
|
+
// stalled, so it must not degrade to π₯±/π¨ while the card sits
|
|
4254
|
+
// unanswered. The verdict path (`resumeReactionAfterVerdict`) flips it
|
|
4255
|
+
// back to a working state the instant you tap.
|
|
4256
|
+
if (activeTurn != null) {
|
|
4257
|
+
activeStatusReactions
|
|
4258
|
+
.get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))
|
|
4259
|
+
?.setAwaiting()
|
|
4260
|
+
}
|
|
4230
4261
|
},
|
|
4231
4262
|
|
|
4232
4263
|
onHeartbeat(_client: IpcClient, _msg: HeartbeatMessage) {
|
|
@@ -8924,6 +8955,7 @@ async function handleInbound(
|
|
|
8924
8955
|
requestId: request_id,
|
|
8925
8956
|
behavior,
|
|
8926
8957
|
})
|
|
8958
|
+
resumeReactionAfterVerdict()
|
|
8927
8959
|
if (msgId != null) {
|
|
8928
8960
|
const emoji = behavior === 'allow' ? 'β
' : 'β'
|
|
8929
8961
|
void bot.api.setMessageReaction(chat_id, msgId, [
|
|
@@ -11759,6 +11791,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
11759
11791
|
}
|
|
11760
11792
|
// Forward to connected bridges β same IPC the button handler uses.
|
|
11761
11793
|
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
|
|
11794
|
+
resumeReactionAfterVerdict()
|
|
11762
11795
|
pendingPermissions.delete(request_id)
|
|
11763
11796
|
process.stderr.write(
|
|
11764
11797
|
`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
|
|
@@ -15409,6 +15442,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15409
15442
|
behavior: 'allow',
|
|
15410
15443
|
rule: chosen.rule,
|
|
15411
15444
|
})
|
|
15445
|
+
// The turn resumes now (independent of the host persistence round-trip
|
|
15446
|
+
// below). Un-park π β working immediately so the operator sees the
|
|
15447
|
+
// agent continue while hostd writes the durable rule.
|
|
15448
|
+
resumeReactionAfterVerdict()
|
|
15412
15449
|
|
|
15413
15450
|
// (3) Decide the persistence path. tryHostdDispatch returns
|
|
15414
15451
|
// "not-configured" when host_control is disabled or the per-agent
|
|
@@ -15562,7 +15599,16 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15562
15599
|
|
|
15563
15600
|
// Forward permission decision to connected bridges
|
|
15564
15601
|
pendingPermissions.delete(request_id)
|
|
15565
|
-
|
|
15602
|
+
// Deterministic "βΆοΈ resumingβ¦" beat (framework-posted, not model text):
|
|
15603
|
+
// the verdict un-parks the suspended turn, so confirm to the operator
|
|
15604
|
+
// that the agent received it and is continuing β closing the "is it
|
|
15605
|
+
// working or did my tap do nothing?" gap. Allow and deny both resume the
|
|
15606
|
+
// turn (deny just hands claude a refusal it then handles).
|
|
15607
|
+
const resumeAgent = process.env.SWITCHROOM_AGENT_NAME
|
|
15608
|
+
const resumeBeat = resumeAgent
|
|
15609
|
+
? `βΆοΈ ${escapeHtmlForTg(resumeAgent)} resumingβ¦`
|
|
15610
|
+
: 'βΆοΈ resumingβ¦'
|
|
15611
|
+
const label = `${behavior === 'allow' ? 'β
Allowed' : 'β Denied'} Β· ${resumeBeat}`
|
|
15566
15612
|
// HTML-escape the source text β same hazard as the scope-commit and
|
|
15567
15613
|
// recent-denial paths above. The permission card body
|
|
15568
15614
|
// (formatPermissionCardBody) appends claude-supplied `description`
|
|
@@ -15590,6 +15636,9 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15590
15636
|
requestId: request_id,
|
|
15591
15637
|
behavior: behavior as 'allow' | 'deny',
|
|
15592
15638
|
})
|
|
15639
|
+
// Un-park the status reaction: π β working, re-arming the stall
|
|
15640
|
+
// watchdog that setAwaiting() suspended.
|
|
15641
|
+
resumeReactionAfterVerdict()
|
|
15593
15642
|
},
|
|
15594
15643
|
})
|
|
15595
15644
|
})
|
|
@@ -53,6 +53,7 @@ export type ReactionState =
|
|
|
53
53
|
| 'web'
|
|
54
54
|
| 'tool'
|
|
55
55
|
| 'compacting'
|
|
56
|
+
| 'awaiting'
|
|
56
57
|
| 'done'
|
|
57
58
|
| 'error'
|
|
58
59
|
| 'stallSoft'
|
|
@@ -78,6 +79,7 @@ export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
|
|
|
78
79
|
coding: ['π¨βπ»', 'β', 'β‘'], // WORKING: writing / running code
|
|
79
80
|
web: ['β‘', 'π€', 'π'], // WORKING: lookup in motion
|
|
80
81
|
compacting:['β', 'π€', 'π'],
|
|
82
|
+
awaiting: ['π', 'π€', 'π'], // BLOCKED ON HUMAN: parked on a permission card
|
|
81
83
|
done: ['π', 'π―', 'π'], // FINISHED: turn_end fired
|
|
82
84
|
error: ['π±', 'π¨', 'π€―'], // NON-TERMINAL β recovery allowed
|
|
83
85
|
stallSoft: ['π₯±', 'π΄', 'π€'],
|
|
@@ -180,6 +182,22 @@ export class StatusReactionController {
|
|
|
180
182
|
this.scheduleState('compacting')
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
/**
|
|
186
|
+
* π β the turn is parked on a human decision (a permission card is
|
|
187
|
+
* waiting for the operator to tap Allow/Deny). Immediate, non-terminal,
|
|
188
|
+
* and crucially SUSPENDS the stall watchdog: a turn blocked on the
|
|
189
|
+
* operator is not stalled, so it must NOT promote to π₯±/π¨ while the
|
|
190
|
+
* card sits unanswered. The next working transition (setTool /
|
|
191
|
+
* setThinking, fired when the verdict resumes the turn) re-arms the
|
|
192
|
+
* watchdog normally. Bypasses debounce so π lands as soon as the card
|
|
193
|
+
* is posted.
|
|
194
|
+
*/
|
|
195
|
+
setAwaiting(): void {
|
|
196
|
+
if (this.finished) return
|
|
197
|
+
this.scheduleState('awaiting', { immediate: true, skipStallReset: true })
|
|
198
|
+
this.clearStallTimers()
|
|
199
|
+
}
|
|
200
|
+
|
|
183
201
|
/**
|
|
184
202
|
* π± β non-terminal error indicator. Paints the error emoji but does
|
|
185
203
|
* NOT end the controller β recovery to a working state is permitted
|
|
@@ -341,6 +341,75 @@ describe('StatusReactionController', () => {
|
|
|
341
341
|
expect(calls).toEqual(['π'])
|
|
342
342
|
})
|
|
343
343
|
|
|
344
|
+
// setAwaiting(): park on π while a permission card waits for the
|
|
345
|
+
// operator. A turn blocked on a human is NOT stalled, so the watchdog
|
|
346
|
+
// must stay quiet β but it re-arms once the verdict resumes work.
|
|
347
|
+
describe('setAwaiting() β park on a human permission decision', () => {
|
|
348
|
+
it('emits π immediately (bypasses debounce)', async () => {
|
|
349
|
+
const { emit, calls } = makeEmitter()
|
|
350
|
+
const ctrl = new StatusReactionController(emit)
|
|
351
|
+
ctrl.setQueued()
|
|
352
|
+
await flush()
|
|
353
|
+
ctrl.setAwaiting()
|
|
354
|
+
await flush()
|
|
355
|
+
expect(calls).toEqual(['π', 'π'])
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('suppresses stall promotion (no π₯±/π¨) while the card sits unanswered', async () => {
|
|
359
|
+
const { emit, calls } = makeEmitter()
|
|
360
|
+
const ctrl = new StatusReactionController(emit)
|
|
361
|
+
ctrl.setQueued()
|
|
362
|
+
ctrl.setTool('Bash') // working: π¨βπ»
|
|
363
|
+
vi.advanceTimersByTime(3500)
|
|
364
|
+
await flush()
|
|
365
|
+
ctrl.setAwaiting()
|
|
366
|
+
await flush()
|
|
367
|
+
// Well past both stall thresholds β awaiting must not yawn or panic.
|
|
368
|
+
vi.advanceTimersByTime(120000)
|
|
369
|
+
await flush()
|
|
370
|
+
expect(calls).not.toContain('π₯±')
|
|
371
|
+
expect(calls).not.toContain('π¨')
|
|
372
|
+
expect(calls[calls.length - 1]).toBe('π')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('re-arms the stall watchdog once a working transition resumes the turn', async () => {
|
|
376
|
+
const { emit, calls } = makeEmitter()
|
|
377
|
+
const ctrl = new StatusReactionController(emit)
|
|
378
|
+
ctrl.setQueued()
|
|
379
|
+
await flush()
|
|
380
|
+
ctrl.setAwaiting()
|
|
381
|
+
await flush()
|
|
382
|
+
vi.advanceTimersByTime(120000) // long human wait β no stall
|
|
383
|
+
await flush()
|
|
384
|
+
expect(calls).toEqual(['π', 'π'])
|
|
385
|
+
|
|
386
|
+
// Verdict dispatched β gateway calls setThinking() to un-park.
|
|
387
|
+
ctrl.setThinking()
|
|
388
|
+
vi.advanceTimersByTime(3500)
|
|
389
|
+
await flush()
|
|
390
|
+
expect(calls).toEqual(['π', 'π', 'π€'])
|
|
391
|
+
|
|
392
|
+
// A genuine post-approval hang must still promote to π₯± β the
|
|
393
|
+
// watchdog was re-armed by the resuming transition.
|
|
394
|
+
vi.advanceTimersByTime(30000)
|
|
395
|
+
await flush()
|
|
396
|
+
expect(calls).toContain('π₯±')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('is a no-op after finalize (cannot resurrect a finished controller)', async () => {
|
|
400
|
+
const { emit, calls } = makeEmitter()
|
|
401
|
+
const ctrl = new StatusReactionController(emit)
|
|
402
|
+
ctrl.setQueued()
|
|
403
|
+
ctrl.finalize('done')
|
|
404
|
+
await flush()
|
|
405
|
+
const snapshot = [...calls]
|
|
406
|
+
ctrl.setAwaiting()
|
|
407
|
+
vi.advanceTimersByTime(5000)
|
|
408
|
+
await flush()
|
|
409
|
+
expect(calls).toEqual(snapshot)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
344
413
|
// hold(): freeze on a WORKING glyph while background sub-agent workers
|
|
345
414
|
// outlive the parent turn, deferring the terminal π (worker-reaction fix).
|
|
346
415
|
describe('hold() β defer π while a background worker runs', () => {
|