switchroom 0.14.28 → 0.14.30
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 +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -0
- package/telegram-plugin/dist/gateway/gateway.js +26 -6
- package/telegram-plugin/dist/server.js +4 -0
- package/telegram-plugin/gateway/gateway.ts +7 -0
- package/telegram-plugin/pending-work-progress.ts +10 -3
- package/telegram-plugin/registry/subagents-schema.ts +35 -0
- package/telegram-plugin/registry/subagents.test.ts +78 -0
- package/telegram-plugin/session-tail.ts +15 -0
- package/telegram-plugin/subagent-watcher.ts +19 -1
- package/telegram-plugin/tests/pending-work-progress.test.ts +22 -4
- package/telegram-plugin/tests/session-tail.test.ts +43 -0
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +2 -1
- package/telegram-plugin/uat/scenarios/jtbd-pending-progress-html-dm.test.ts +2 -1
package/dist/cli/switchroom.js
CHANGED
|
@@ -49420,8 +49420,8 @@ var {
|
|
|
49420
49420
|
} = import__.default;
|
|
49421
49421
|
|
|
49422
49422
|
// src/build-info.ts
|
|
49423
|
-
var VERSION = "0.14.
|
|
49424
|
-
var COMMIT_SHA = "
|
|
49423
|
+
var VERSION = "0.14.30";
|
|
49424
|
+
var COMMIT_SHA = "84f50bd2";
|
|
49425
49425
|
|
|
49426
49426
|
// src/cli/agent.ts
|
|
49427
49427
|
init_source();
|
package/package.json
CHANGED
|
@@ -23349,6 +23349,10 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
23349
23349
|
events.push({ kind: "sub_agent_text", agentId, text });
|
|
23350
23350
|
}
|
|
23351
23351
|
}
|
|
23352
|
+
const stopReason = message?.stop_reason;
|
|
23353
|
+
if (stopReason === "end_turn") {
|
|
23354
|
+
events.push({ kind: "sub_agent_turn_end", agentId });
|
|
23355
|
+
}
|
|
23352
23356
|
return events;
|
|
23353
23357
|
}
|
|
23354
23358
|
if (type === "system" && obj.subtype === "turn_duration") {
|
|
@@ -38932,7 +38932,7 @@ var EDIT_INTERVAL_MS = 60000;
|
|
|
38932
38932
|
var POLL_INTERVAL_MS = 5000;
|
|
38933
38933
|
var MAX_LIFETIME_MS = 30 * 60000;
|
|
38934
38934
|
var TELEGRAM_MSG_CAP2 = 4000;
|
|
38935
|
-
var SUFFIX_RE = /\n\n\u2014 still working \(\d+m\)
|
|
38935
|
+
var SUFFIX_RE = /\n\n\u2014 still working \(\d+m\)( \u00b7 message me anytime, I'll keep you posted)?$/;
|
|
38936
38936
|
var stateByKey = new Map;
|
|
38937
38937
|
var timer2 = null;
|
|
38938
38938
|
var activeDeps2 = null;
|
|
@@ -39044,7 +39044,7 @@ function tick2(now) {
|
|
|
39044
39044
|
const minutes = Math.max(1, Math.round(elapsed / 60000));
|
|
39045
39045
|
const suffix = `
|
|
39046
39046
|
|
|
39047
|
-
\u2014 still working (${minutes}m)`;
|
|
39047
|
+
\u2014 still working (${minutes}m) \u00b7 message me anytime, I'll keep you posted`;
|
|
39048
39048
|
const newText = s.anchorOriginalText + suffix;
|
|
39049
39049
|
if (newText.length > TELEGRAM_MSG_CAP2) {
|
|
39050
39050
|
s.lastEditAt = now;
|
|
@@ -49160,6 +49160,10 @@ function projectSubagentLine(line, agentId, state4) {
|
|
|
49160
49160
|
events.push({ kind: "sub_agent_text", agentId, text });
|
|
49161
49161
|
}
|
|
49162
49162
|
}
|
|
49163
|
+
const stopReason = message?.stop_reason;
|
|
49164
|
+
if (stopReason === "end_turn") {
|
|
49165
|
+
events.push({ kind: "sub_agent_turn_end", agentId });
|
|
49166
|
+
}
|
|
49163
49167
|
return events;
|
|
49164
49168
|
}
|
|
49165
49169
|
if (type === "system" && obj.subtype === "turn_duration") {
|
|
@@ -49297,6 +49301,10 @@ function redactSecrets(text) {
|
|
|
49297
49301
|
return out;
|
|
49298
49302
|
}
|
|
49299
49303
|
// registry/subagents-schema.ts
|
|
49304
|
+
function countRunningBackgroundSubagents(db2) {
|
|
49305
|
+
const row = db2.prepare("SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'").get();
|
|
49306
|
+
return row?.n ?? 0;
|
|
49307
|
+
}
|
|
49300
49308
|
function recordSubagentEnd(db2, args) {
|
|
49301
49309
|
db2.prepare(`
|
|
49302
49310
|
UPDATE subagents
|
|
@@ -50006,6 +50014,15 @@ function startSubagentWatcher(config) {
|
|
|
50006
50014
|
},
|
|
50007
50015
|
getRegistry() {
|
|
50008
50016
|
return registry;
|
|
50017
|
+
},
|
|
50018
|
+
countRunningBackgroundWorkers() {
|
|
50019
|
+
if (db2 == null)
|
|
50020
|
+
return null;
|
|
50021
|
+
try {
|
|
50022
|
+
return countRunningBackgroundSubagents(db2);
|
|
50023
|
+
} catch {
|
|
50024
|
+
return null;
|
|
50025
|
+
}
|
|
50009
50026
|
}
|
|
50010
50027
|
};
|
|
50011
50028
|
}
|
|
@@ -51642,10 +51659,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51642
51659
|
}
|
|
51643
51660
|
|
|
51644
51661
|
// ../src/build-info.ts
|
|
51645
|
-
var VERSION = "0.14.
|
|
51646
|
-
var COMMIT_SHA = "
|
|
51647
|
-
var COMMIT_DATE = "2026-06-
|
|
51648
|
-
var LATEST_PR =
|
|
51662
|
+
var VERSION = "0.14.30";
|
|
51663
|
+
var COMMIT_SHA = "84f50bd2";
|
|
51664
|
+
var COMMIT_DATE = "2026-06-01T06:07:00Z";
|
|
51665
|
+
var LATEST_PR = 2058;
|
|
51649
51666
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51650
51667
|
|
|
51651
51668
|
// gateway/boot-version.ts
|
|
@@ -53063,6 +53080,9 @@ function finalizeStatusReaction(chatId, threadId, reason = "done") {
|
|
|
53063
53080
|
purgeReactionTracking(key);
|
|
53064
53081
|
}
|
|
53065
53082
|
function countRunningWorkers() {
|
|
53083
|
+
const dbCount = subagentWatcher?.countRunningBackgroundWorkers?.();
|
|
53084
|
+
if (dbCount != null)
|
|
53085
|
+
return dbCount;
|
|
53066
53086
|
const reg = subagentWatcher?.getRegistry();
|
|
53067
53087
|
if (reg == null)
|
|
53068
53088
|
return 0;
|
|
@@ -17387,6 +17387,10 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
17387
17387
|
events.push({ kind: "sub_agent_text", agentId, text });
|
|
17388
17388
|
}
|
|
17389
17389
|
}
|
|
17390
|
+
const stopReason = message?.stop_reason;
|
|
17391
|
+
if (stopReason === "end_turn") {
|
|
17392
|
+
events.push({ kind: "sub_agent_turn_end", agentId });
|
|
17393
|
+
}
|
|
17390
17394
|
return events;
|
|
17391
17395
|
}
|
|
17392
17396
|
if (type === "system" && obj.subtype === "turn_duration") {
|
|
@@ -2119,6 +2119,13 @@ function finalizeStatusReaction(
|
|
|
2119
2119
|
* fired its `done`/`failed` onFinish no longer counts here.
|
|
2120
2120
|
*/
|
|
2121
2121
|
function countRunningWorkers(): number {
|
|
2122
|
+
// Prefer the dispatch-time DB count: a background worker's row is INSERTed
|
|
2123
|
+
// `status='running'` when its `Agent` tool_use fires, i.e. BEFORE the parent
|
|
2124
|
+
// turn ends. The registry below is populated by on-disk file discovery, which
|
|
2125
|
+
// lags dispatch by a poll/fswatch tick — so a just-dispatched worker was
|
|
2126
|
+
// invisible to the deferred-done gate and the 👍 promoted prematurely.
|
|
2127
|
+
const dbCount = subagentWatcher?.countRunningBackgroundWorkers?.()
|
|
2128
|
+
if (dbCount != null) return dbCount
|
|
2122
2129
|
const reg = subagentWatcher?.getRegistry()
|
|
2123
2130
|
if (reg == null) return 0
|
|
2124
2131
|
let n = 0
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
* turn_end with pending+anchor → activate the timer for the key
|
|
36
36
|
* tick (every 5s, edit every → editMessageText against the anchor
|
|
37
37
|
* EDIT_INTERVAL_MS) appending/refreshing the suffix
|
|
38
|
-
* " — still working (Nm)
|
|
38
|
+
* " — still working (Nm) · message me
|
|
39
|
+
* anytime, I'll keep you posted"
|
|
40
|
+
* (the reachability clause signals the
|
|
41
|
+
* agent is still listening while a
|
|
42
|
+
* background worker runs — issue PR3)
|
|
39
43
|
* inbound user message → clear (user re-engaged or moved on)
|
|
40
44
|
* subagent_handback inject → clear (model about to re-engage)
|
|
41
45
|
* MAX_LIFETIME_MS budget cap → clear (give up; 30 min default)
|
|
@@ -70,10 +74,13 @@ export const TELEGRAM_MSG_CAP = 4000
|
|
|
70
74
|
/**
|
|
71
75
|
* Regex matching the suffix we append. Used to strip a prior suffix
|
|
72
76
|
* before appending the next one. The (\d+) covers "1m" / "12m" / etc.
|
|
77
|
+
* The reachability clause is optional so anchors carrying a pre-v0.14.30
|
|
78
|
+
* suffix (no clause) are still stripped during a rolling upgrade.
|
|
73
79
|
* Kept anchored to end-of-string so it only matches OUR suffix, not
|
|
74
80
|
* something the model happened to write.
|
|
75
81
|
*/
|
|
76
|
-
const SUFFIX_RE =
|
|
82
|
+
const SUFFIX_RE =
|
|
83
|
+
/\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/
|
|
77
84
|
|
|
78
85
|
export interface PendingProgressEditCtx {
|
|
79
86
|
chatId: string
|
|
@@ -380,7 +387,7 @@ function tick(now: number): void {
|
|
|
380
387
|
// user-visible counter reads honestly (we only edit at intervals
|
|
381
388
|
// ≥ EDIT_INTERVAL_MS = 60s).
|
|
382
389
|
const minutes = Math.max(1, Math.round(elapsed / 60_000))
|
|
383
|
-
const suffix = `\n\n— still working (${minutes}m)`
|
|
390
|
+
const suffix = `\n\n— still working (${minutes}m) · message me anytime, I'll keep you posted`
|
|
384
391
|
const newText = s.anchorOriginalText + suffix
|
|
385
392
|
|
|
386
393
|
if (newText.length > TELEGRAM_MSG_CAP) {
|
|
@@ -360,6 +360,41 @@ export function getSubagentByJsonlId(db: SqliteDatabase, jsonlAgentId: string):
|
|
|
360
360
|
return row ? mapSubagentRow(row) : null
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Count background subagents that have not yet reached a terminal state.
|
|
365
|
+
*
|
|
366
|
+
* This is the dispatch-time source of truth for "is a background worker still
|
|
367
|
+
* running" — the row is INSERTed with `status='running'` by `recordSubagentStart`
|
|
368
|
+
* the moment the parent's `Agent` tool_use fires (keyed on the `toolu_…` id),
|
|
369
|
+
* which is BEFORE the parent's turn ends. The deferred-done-reaction gate reads
|
|
370
|
+
* this so it holds the 👍 the instant a worker is dispatched, rather than
|
|
371
|
+
* snapshotting the file-discovery registry (which lags dispatch by a poll/fswatch
|
|
372
|
+
* tick and so missed just-dispatched workers — the premature-👍 race).
|
|
373
|
+
*
|
|
374
|
+
* Counts `running` ONLY — `stalled` is deliberately excluded. `stalled` is NOT
|
|
375
|
+
* a terminal status: the reaper (`reapStuckRunningRows`) transitions a row to
|
|
376
|
+
* `stalled`, never to `completed`/`failed`. A genuinely-orphaned background row
|
|
377
|
+
* — one INSERTed at dispatch whose JSONL was never linked, so no activity ever
|
|
378
|
+
* bumped it and the in-memory silent-stall synthesis never terminalised it —
|
|
379
|
+
* sits in `stalled` indefinitely (the 1h reaper TTL is the only thing that
|
|
380
|
+
* moves it off `running`). Counting `stalled` would wedge the deferred 👍 above
|
|
381
|
+
* zero forever for that row (`reaction-defer.ts` `promote()` bails while the
|
|
382
|
+
* count is > 0). A live-but-quiet worker, by contrast, is driven to `completed`
|
|
383
|
+
* by the watcher's terminal paths (end_turn signal OR silent-stall synthesis,
|
|
384
|
+
* both call `recordSubagentEnd`) long before the 1h reaper, and a stalled row
|
|
385
|
+
* that genuinely resumes is flipped back to `running` by `recordSubagentResume`
|
|
386
|
+
* — so excluding `stalled` never releases the 👍 on a worker that's merely
|
|
387
|
+
* paused rather than dead.
|
|
388
|
+
*/
|
|
389
|
+
export function countRunningBackgroundSubagents(db: SqliteDatabase): number {
|
|
390
|
+
const row = db
|
|
391
|
+
.prepare(
|
|
392
|
+
"SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'",
|
|
393
|
+
)
|
|
394
|
+
.get() as { n: number } | undefined
|
|
395
|
+
return row?.n ?? 0
|
|
396
|
+
}
|
|
397
|
+
|
|
363
398
|
/**
|
|
364
399
|
* Record that a subagent has reached a terminal state (completed or failed).
|
|
365
400
|
* Sets `ended_at`, `status`, and optionally `result_summary`.
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
bumpSubagentActivity,
|
|
29
29
|
getSubagent,
|
|
30
30
|
reapStuckRunningRows,
|
|
31
|
+
countRunningBackgroundSubagents,
|
|
31
32
|
} from './subagents-schema.js'
|
|
32
33
|
|
|
33
34
|
// ---------------------------------------------------------------------------
|
|
@@ -182,6 +183,83 @@ describe('recordSubagentStart + recordSubagentEnd happy path', () => {
|
|
|
182
183
|
})
|
|
183
184
|
})
|
|
184
185
|
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// countRunningBackgroundSubagents — the dispatch-time gate for the
|
|
188
|
+
// deferred-done 👍 reaction. A row counts as "still running" the instant
|
|
189
|
+
// recordSubagentStart inserts it (status='running'), closing the
|
|
190
|
+
// file-discovery registration race that promoted the 👍 prematurely.
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('countRunningBackgroundSubagents', () => {
|
|
194
|
+
it('counts a background worker the moment it starts (before any terminal)', () => {
|
|
195
|
+
const db = openFreshSubagentsDbInMemory()
|
|
196
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
197
|
+
recordSubagentStart(db, { id: 'bg-1', background: true, startedAt: 1000 })
|
|
198
|
+
expect(countRunningBackgroundSubagents(db)).toBe(1)
|
|
199
|
+
db.close()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('does NOT count a stalled worker — stalled is the reaper sink, never terminalised', () => {
|
|
203
|
+
// A `stalled` row is NOT terminal and is NOT actively running. The only
|
|
204
|
+
// way a background row reaches `stalled` is the 1h reaper firing on a row
|
|
205
|
+
// that never linked a JSONL (no activity bumps, no silent-stall synthesis
|
|
206
|
+
// to drive it to `completed`) — i.e. an orphaned/dead dispatch. Counting it
|
|
207
|
+
// would wedge the deferred 👍 above zero forever (promote() bails while the
|
|
208
|
+
// count is > 0). A live-but-quiet worker terminalises to `completed` long
|
|
209
|
+
// before the reaper, so `stalled` always means "dead" here.
|
|
210
|
+
const db = openFreshSubagentsDbInMemory()
|
|
211
|
+
recordSubagentStart(db, { id: 'bg-2', background: true, startedAt: 1000 })
|
|
212
|
+
recordSubagentStall(db, { id: 'bg-2', stalledAt: 1500 })
|
|
213
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
214
|
+
db.close()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('a row reaped to stalled does not keep the gate above zero (permanent-👍-hold regression guard)', () => {
|
|
218
|
+
// Regression guard for the orphaned-dispatch wedge: dispatch inserts a
|
|
219
|
+
// `running` row, the JSONL never links, and the reaper transitions it to
|
|
220
|
+
// `stalled`. The deferred-👍 gate must read zero so promote() can fire —
|
|
221
|
+
// counting the reaped row would hold the 👍 forever.
|
|
222
|
+
const db = openFreshSubagentsDbInMemory()
|
|
223
|
+
recordSubagentStart(db, { id: 'bg-orphan', background: true, startedAt: 1000 })
|
|
224
|
+
expect(countRunningBackgroundSubagents(db)).toBe(1)
|
|
225
|
+
const result = reapStuckRunningRows(db, { ttlMs: 500, now: 5000 })
|
|
226
|
+
expect(result.reaped).toBe(1)
|
|
227
|
+
expect(getSubagent(db, 'bg-orphan')!.status).toBe('stalled')
|
|
228
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
229
|
+
db.close()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('re-counts a stalled worker that resumes — recordSubagentResume flips it back to running', () => {
|
|
233
|
+
// A worker that's merely paused (not dead) and resumes JSONL activity is
|
|
234
|
+
// flipped stalled → running by recordSubagentResume, so the gate holds the
|
|
235
|
+
// 👍 again. Excluding `stalled` from the count never releases the 👍 on a
|
|
236
|
+
// worker that's only paused.
|
|
237
|
+
const db = openFreshSubagentsDbInMemory()
|
|
238
|
+
recordSubagentStart(db, { id: 'bg-resume', background: true, startedAt: 1000 })
|
|
239
|
+
recordSubagentStall(db, { id: 'bg-resume', stalledAt: 1500 })
|
|
240
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
241
|
+
recordSubagentResume(db, { id: 'bg-resume', resumedAt: 2000 })
|
|
242
|
+
expect(countRunningBackgroundSubagents(db)).toBe(1)
|
|
243
|
+
db.close()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('drops to zero once the worker reaches a terminal status', () => {
|
|
247
|
+
const db = openFreshSubagentsDbInMemory()
|
|
248
|
+
recordSubagentStart(db, { id: 'bg-3', background: true, startedAt: 1000 })
|
|
249
|
+
expect(countRunningBackgroundSubagents(db)).toBe(1)
|
|
250
|
+
recordSubagentEnd(db, { id: 'bg-3', endedAt: 2000, status: 'completed' })
|
|
251
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
252
|
+
db.close()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('ignores foreground subagents — only background workers gate the reaction', () => {
|
|
256
|
+
const db = openFreshSubagentsDbInMemory()
|
|
257
|
+
recordSubagentStart(db, { id: 'fg-1', background: false, startedAt: 1000 })
|
|
258
|
+
expect(countRunningBackgroundSubagents(db)).toBe(0)
|
|
259
|
+
db.close()
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
185
263
|
// ---------------------------------------------------------------------------
|
|
186
264
|
// Test 4 — start → stall → end
|
|
187
265
|
// ---------------------------------------------------------------------------
|
|
@@ -390,6 +390,21 @@ export function projectSubagentLine(
|
|
|
390
390
|
events.push({ kind: 'sub_agent_text', agentId, text })
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
|
+
// Authoritative early terminal: a background `Agent` worker's JSONL on
|
|
394
|
+
// claude ≥2.1.156 never writes the `system/turn_duration` line below, so
|
|
395
|
+
// the watcher used to only learn the worker finished via the ~5-min
|
|
396
|
+
// silent-stall synthesis net — leaving the card stuck "running" and the
|
|
397
|
+
// deferred 👍 held for minutes after the work was actually done. The
|
|
398
|
+
// worker DOES write a final assistant message with
|
|
399
|
+
// `stop_reason: 'end_turn'` (a tool-using turn is `'tool_use'` and keeps
|
|
400
|
+
// going), so treat that as the terminal signal. Emitted AFTER the content
|
|
401
|
+
// events so the final text/preamble still renders; the watcher's turn_end
|
|
402
|
+
// handler is guarded on `state === 'running'`, so a later real
|
|
403
|
+
// turn_duration line is a no-op.
|
|
404
|
+
const stopReason = message?.stop_reason as string | undefined
|
|
405
|
+
if (stopReason === 'end_turn') {
|
|
406
|
+
events.push({ kind: 'sub_agent_turn_end', agentId })
|
|
407
|
+
}
|
|
393
408
|
return events
|
|
394
409
|
}
|
|
395
410
|
|
|
@@ -43,7 +43,7 @@ import { homedir } from 'os'
|
|
|
43
43
|
import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
|
|
44
44
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
45
45
|
import { escapeHtml, truncate } from './card-format.js'
|
|
46
|
-
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows } from './registry/subagents-schema.js'
|
|
46
|
+
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
|
|
47
47
|
import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
|
|
48
48
|
|
|
49
49
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
@@ -377,6 +377,13 @@ export interface SubagentWatcherHandle {
|
|
|
377
377
|
stop(): void
|
|
378
378
|
/** Snapshot of current registry for tests/inspection. */
|
|
379
379
|
getRegistry(): ReadonlyMap<string, WorkerEntry>
|
|
380
|
+
/**
|
|
381
|
+
* Count background workers still in flight, read from the dispatch-time DB
|
|
382
|
+
* (not the file-discovery registry). Returns null when no DB is wired so the
|
|
383
|
+
* caller can fall back to the registry snapshot. Drives the deferred-done
|
|
384
|
+
* reaction gate — see `countRunningBackgroundSubagents`.
|
|
385
|
+
*/
|
|
386
|
+
countRunningBackgroundWorkers(): number | null
|
|
380
387
|
}
|
|
381
388
|
|
|
382
389
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -1498,5 +1505,16 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1498
1505
|
getRegistry(): ReadonlyMap<string, WorkerEntry> {
|
|
1499
1506
|
return registry
|
|
1500
1507
|
},
|
|
1508
|
+
|
|
1509
|
+
countRunningBackgroundWorkers(): number | null {
|
|
1510
|
+
if (db == null) return null
|
|
1511
|
+
try {
|
|
1512
|
+
return countRunningBackgroundSubagents(db)
|
|
1513
|
+
} catch {
|
|
1514
|
+
// A torn/locked DB read must not wedge the reaction gate — fall back
|
|
1515
|
+
// to the registry snapshot by returning null.
|
|
1516
|
+
return null
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1501
1519
|
}
|
|
1502
1520
|
}
|
|
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
|
|
|
139
139
|
expect(cap.edits).toHaveLength(1)
|
|
140
140
|
expect(cap.edits[0].messageId).toBe(100)
|
|
141
141
|
expect(cap.edits[0].newText).toBe(
|
|
142
|
-
|
|
142
|
+
"Background sleep running; awaiting completion.\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
143
143
|
)
|
|
144
144
|
|
|
145
145
|
// Tick at 3 intervals total — second edit, "3m".
|
|
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
|
|
|
148
148
|
await flush()
|
|
149
149
|
expect(cap.edits).toHaveLength(2)
|
|
150
150
|
expect(cap.edits[1].newText).toBe(
|
|
151
|
-
|
|
151
|
+
"Background sleep running; awaiting completion.\n\n— still working (3m) · message me anytime, I'll keep you posted",
|
|
152
152
|
)
|
|
153
153
|
})
|
|
154
154
|
|
|
@@ -168,7 +168,25 @@ describe('pending-work-progress', () => {
|
|
|
168
168
|
await flush()
|
|
169
169
|
// The new edit should be based on 'worker dispatched' alone.
|
|
170
170
|
expect(cap.edits[0].newText).toBe(
|
|
171
|
-
|
|
171
|
+
"worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('strips a prior NEW-shape suffix (with reachability clause) too', async () => {
|
|
176
|
+
const cap = setup()
|
|
177
|
+
startTurn(KEY)
|
|
178
|
+
noteAsyncDispatch(KEY)
|
|
179
|
+
noteOutbound(KEY, {
|
|
180
|
+
messageId: 100,
|
|
181
|
+
text:
|
|
182
|
+
"worker dispatched\n\n— still working (12m) · message me anytime, I'll keep you posted",
|
|
183
|
+
})
|
|
184
|
+
noteTurnEnd(KEY)
|
|
185
|
+
cap.now = EDIT_INTERVAL_MS
|
|
186
|
+
__tickForTests(cap.now)
|
|
187
|
+
await flush()
|
|
188
|
+
expect(cap.edits[0].newText).toBe(
|
|
189
|
+
"worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
172
190
|
)
|
|
173
191
|
})
|
|
174
192
|
|
|
@@ -338,7 +356,7 @@ describe('pending-work-progress', () => {
|
|
|
338
356
|
expect(cap.edits).toHaveLength(1)
|
|
339
357
|
expect(cap.edits[0].parseMode).toBe('HTML')
|
|
340
358
|
expect(cap.edits[0].newText).toBe(
|
|
341
|
-
|
|
359
|
+
"<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m) · message me anytime, I'll keep you posted",
|
|
342
360
|
)
|
|
343
361
|
})
|
|
344
362
|
|
|
@@ -492,6 +492,49 @@ describe('projectSubagentLine', () => {
|
|
|
492
492
|
expect(events).toEqual([{ kind: 'sub_agent_turn_end', agentId: 'X' }])
|
|
493
493
|
})
|
|
494
494
|
|
|
495
|
+
it('emits sub_agent_turn_end after the text when the final assistant message stop_reason is end_turn', () => {
|
|
496
|
+
// Background `Agent` workers (claude ≥2.1.156) never write the
|
|
497
|
+
// system/turn_duration line, only a final assistant message with
|
|
498
|
+
// stop_reason 'end_turn'. That IS the authoritative completion signal —
|
|
499
|
+
// without treating it as terminal the card hung "running" until the
|
|
500
|
+
// ~5-min stall-synthesis net fired (the screenshot bug).
|
|
501
|
+
const st = { hasEmittedStart: true }
|
|
502
|
+
const events = projectSubagentLine(
|
|
503
|
+
JSON.stringify({
|
|
504
|
+
type: 'assistant',
|
|
505
|
+
message: {
|
|
506
|
+
stop_reason: 'end_turn',
|
|
507
|
+
content: [{ type: 'text', text: 'Done. Fixed the bug.' }],
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
'X',
|
|
511
|
+
st,
|
|
512
|
+
)
|
|
513
|
+
// Text first (so the final summary still renders), turn_end last.
|
|
514
|
+
expect(events).toEqual([
|
|
515
|
+
{ kind: 'sub_agent_text', agentId: 'X', text: 'Done. Fixed the bug.' },
|
|
516
|
+
{ kind: 'sub_agent_turn_end', agentId: 'X' },
|
|
517
|
+
])
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('does NOT emit sub_agent_turn_end for a tool-using assistant message (stop_reason tool_use)', () => {
|
|
521
|
+
// A mid-run assistant message that calls a tool has stop_reason 'tool_use'
|
|
522
|
+
// and keeps going — it must not be mistaken for completion.
|
|
523
|
+
const st = { hasEmittedStart: true }
|
|
524
|
+
const events = projectSubagentLine(
|
|
525
|
+
JSON.stringify({
|
|
526
|
+
type: 'assistant',
|
|
527
|
+
message: {
|
|
528
|
+
stop_reason: 'tool_use',
|
|
529
|
+
content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
|
|
530
|
+
},
|
|
531
|
+
}),
|
|
532
|
+
'X',
|
|
533
|
+
st,
|
|
534
|
+
)
|
|
535
|
+
expect(events.some((e) => e.kind === 'sub_agent_turn_end')).toBe(false)
|
|
536
|
+
})
|
|
537
|
+
|
|
495
538
|
it('skips malformed lines silently', () => {
|
|
496
539
|
const st = { hasEmittedStart: false }
|
|
497
540
|
expect(projectSubagentLine('{not-json', 'X', st)).toEqual([])
|
|
@@ -81,7 +81,8 @@ interface TrailEntry {
|
|
|
81
81
|
text: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const SUFFIX_RE =
|
|
84
|
+
const SUFFIX_RE =
|
|
85
|
+
/\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
|
|
85
86
|
|
|
86
87
|
function pad(s: string, n: number): string {
|
|
87
88
|
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
@@ -40,7 +40,8 @@ const PROMPT =
|
|
|
40
40
|
`the bash, send that one HTML reply, end your turn. When it finishes ` +
|
|
41
41
|
`much later, reply with the single word "done".`;
|
|
42
42
|
|
|
43
|
-
const SUFFIX_RE =
|
|
43
|
+
const SUFFIX_RE =
|
|
44
|
+
/\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
|
|
44
45
|
|
|
45
46
|
describe("uat: pending-progress edit preserves HTML formatting (#1698 regression gate)", () => {
|
|
46
47
|
it(
|