switchroom 0.18.20 → 0.18.21
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 +24 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +45 -6
- package/telegram-plugin/gateway/gateway.ts +19 -1
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/subagent-watcher.ts +86 -1
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.18.
|
|
2123
|
+
var VERSION = "0.18.21", COMMIT_SHA = "c237ff59";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -23799,6 +23799,14 @@ function renderDevProtocolFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
|
23799
23799
|
const template = import_handlebars.default.compile(source, { noEscape: true });
|
|
23800
23800
|
return template(context).trimEnd();
|
|
23801
23801
|
}
|
|
23802
|
+
function renderDelegationGoldenRuleFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
23803
|
+
const fragPath = join4(resolve4(profilesRoot, "_shared"), "delegation-golden-rule.md.hbs");
|
|
23804
|
+
if (!existsSync7(fragPath))
|
|
23805
|
+
return "";
|
|
23806
|
+
const source = readFileSync7(fragPath, "utf-8");
|
|
23807
|
+
const template = import_handlebars.default.compile(source, { noEscape: true });
|
|
23808
|
+
return template(context).trimEnd();
|
|
23809
|
+
}
|
|
23802
23810
|
function renderReplyDisciplineFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
23803
23811
|
const fragPath = join4(resolve4(profilesRoot, "_shared"), "reply-discipline.md.hbs");
|
|
23804
23812
|
if (!existsSync7(fragPath))
|
|
@@ -27579,6 +27587,13 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
|
|
|
27579
27587
|
rendered = rendered.trimEnd() + `
|
|
27580
27588
|
|
|
27581
27589
|
` + devProtocol + `
|
|
27590
|
+
`;
|
|
27591
|
+
}
|
|
27592
|
+
const delegationGoldenRule = renderDelegationGoldenRuleFragment(context);
|
|
27593
|
+
if (delegationGoldenRule) {
|
|
27594
|
+
rendered = rendered.trimEnd() + `
|
|
27595
|
+
|
|
27596
|
+
` + delegationGoldenRule + `
|
|
27582
27597
|
`;
|
|
27583
27598
|
}
|
|
27584
27599
|
}
|
|
@@ -28462,6 +28477,13 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
|
|
|
28462
28477
|
rendered = rendered.trimEnd() + `
|
|
28463
28478
|
|
|
28464
28479
|
` + devProtocol + `
|
|
28480
|
+
`;
|
|
28481
|
+
}
|
|
28482
|
+
const delegationGoldenRule = renderDelegationGoldenRuleFragment(claudeContext);
|
|
28483
|
+
if (delegationGoldenRule) {
|
|
28484
|
+
rendered = rendered.trimEnd() + `
|
|
28485
|
+
|
|
28486
|
+
` + delegationGoldenRule + `
|
|
28465
28487
|
`;
|
|
28466
28488
|
}
|
|
28467
28489
|
if (agentConfig.claude_md_raw) {
|
|
@@ -78779,6 +78801,7 @@ function applySubagentsSchema(db) {
|
|
|
78779
78801
|
db.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
|
|
78780
78802
|
}
|
|
78781
78803
|
db.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
|
|
78804
|
+
db.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
|
|
78782
78805
|
}
|
|
78783
78806
|
function mapSubagentRow(row) {
|
|
78784
78807
|
return {
|
|
@@ -26605,7 +26605,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26605
26605
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26606
26606
|
|
|
26607
26607
|
// src/build-info.ts
|
|
26608
|
-
var VERSION = "0.18.
|
|
26608
|
+
var VERSION = "0.18.21";
|
|
26609
26609
|
|
|
26610
26610
|
// src/cli/resolve-version.ts
|
|
26611
26611
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.21",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Delegation — the last word
|
|
2
|
+
|
|
3
|
+
This is the tail reminder on purpose: whatever the grounding, execution-bias, and development-protocol guidance above told you to *do*, the default way to do execution-class work is to **delegate it, not to run it inline.**
|
|
4
|
+
|
|
5
|
+
- **Golden rule: when in doubt, delegate.** Any code change, research spanning 3+ file reads, file/report generation, build/deploy, or a task needing 3+ sequential tool calls without user input goes to a sub-agent (`@worker` for changes, `@researcher` for research, `@reviewer` for review) — per the Sub-Agent Delegation section. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention.
|
|
6
|
+
- **The main session is for conversation.** Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response. "Act in-turn" is satisfied by dispatching the worker, not by doing the work yourself.
|
|
7
|
+
- **Anti-pattern:** starting a task inline "because it's almost done", then racking up 5+ tool calls in the foreground. If it's execution-class, hand it off at the first tool call, not the fifth.
|
|
8
|
+
|
|
9
|
+
If no sub-agents are configured, do the work yourself.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
How development work gets done here — orient, clarify, align, ship, communicate. These are procedural rules for any substantive coding, infra, or debugging task. For the long-form playbook (design reports, adversarial review structure, re-review verdicts), load the bundled `dev-protocol` skill before starting substantive development work.
|
|
4
4
|
|
|
5
|
+
**This protocol governs HOW delegated work is done, not a license to do it inline.** Substantive dev work is execution-class — per the Sub-Agent Delegation section it is dispatched to `@worker`/sub-agents so the foreground session stays free for the user. The orient / clarify / design-align / pipeline / communicate rules below bind that delegated work (and the sub-agent doing it); they are not an invitation for the main session to start editing, building, and testing in-turn instead of delegating.
|
|
6
|
+
|
|
5
7
|
### Orient — ground before you build
|
|
6
8
|
|
|
7
9
|
- **Validate, don't assume.** Read the actual code, config, and system state before forming a theory. Never assert a fact you haven't checked this turn.
|
|
@@ -14,5 +14,5 @@ When you genuinely can't verify something this turn, say so plainly ("I haven't
|
|
|
14
14
|
|
|
15
15
|
How you should decide what to do next. These are procedural rules, not vibe.
|
|
16
16
|
|
|
17
|
-
- **Act in-turn.** If the request is actionable,
|
|
18
|
-
- **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
|
|
17
|
+
- **Act in-turn.** If the request is actionable, move it forward this turn. Don't finish with a plan or promise when tools can advance it. **But "act" composes with the Sub-Agent Delegation rules — it does not override them:** for an execution-class task (any code change, research spanning 3+ file reads, multi-step infra, report generation — see that section), the in-turn act IS dispatching the sub-agent, not doing the work inline. Acting immediately and delegating are the same move here; keep your own turn short and let the worker execute.
|
|
18
|
+
- **Non-final turn:** use tools to advance (dispatch the worker when the task is execution-class), or ask the one clarifying question that unblocks safe progress. One question, not five.
|
|
@@ -49,7 +49,7 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
|
|
|
49
49
|
|
|
50
50
|
## Sub-Agent Delegation
|
|
51
51
|
|
|
52
|
-
If sub-agents are configured, delegate implementation to `@worker` (background, own worktree)
|
|
52
|
+
**Golden rule: when in doubt, delegate.** The main session is for conversation; execution belongs in sub-agents. If sub-agents are configured, delegate implementation to `@worker` (background, own worktree), research spanning 3+ file reads to `@researcher` (background), and review to `@reviewer`. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention. Keep your turns short — dispatch and acknowledge quickly so you stay available for the user; the user should never wait more than 10 seconds for a response. Acting in-turn on an execution-class task means dispatching the worker, not doing the work inline.
|
|
53
53
|
|
|
54
54
|
If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
|
|
55
55
|
|
|
@@ -77259,6 +77259,9 @@ function decideSubagentProgress(input) {
|
|
|
77259
77259
|
if (isEnvFlagOn(input.disableEnvValue)) {
|
|
77260
77260
|
return { deliver: false, reason: "env-disabled" };
|
|
77261
77261
|
}
|
|
77262
|
+
if (input.skeleton === true) {
|
|
77263
|
+
return { deliver: false, reason: "skeleton-liveness" };
|
|
77264
|
+
}
|
|
77262
77265
|
if (!input.isBackground) {
|
|
77263
77266
|
return { deliver: false, reason: "foreground" };
|
|
77264
77267
|
}
|
|
@@ -79121,8 +79124,40 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
|
|
|
79121
79124
|
tail.cursor = 0;
|
|
79122
79125
|
tail.pendingPartial = "";
|
|
79123
79126
|
}
|
|
79124
|
-
if (stat.size === tail.cursor)
|
|
79127
|
+
if (stat.size === tail.cursor) {
|
|
79128
|
+
if (onProgress != null && entry.state === "running" && !entry.historical) {
|
|
79129
|
+
let hasChild = false;
|
|
79130
|
+
if (db2 != null) {
|
|
79131
|
+
try {
|
|
79132
|
+
const kid = db2.prepare("SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1").get(entry.agentId);
|
|
79133
|
+
hasChild = kid != null;
|
|
79134
|
+
} catch (kidErr) {
|
|
79135
|
+
log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${kidErr.message}`);
|
|
79136
|
+
}
|
|
79137
|
+
}
|
|
79138
|
+
if (!hasChild) {
|
|
79139
|
+
try {
|
|
79140
|
+
onProgress({
|
|
79141
|
+
agentId: entry.agentId,
|
|
79142
|
+
description: entry.description,
|
|
79143
|
+
latestSummary: "",
|
|
79144
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
79145
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
79146
|
+
setBucketIdx: (b) => {
|
|
79147
|
+
entry.lastProgressBucketIdx = b;
|
|
79148
|
+
},
|
|
79149
|
+
lastTool: entry.lastTool,
|
|
79150
|
+
toolCount: entry.toolCount,
|
|
79151
|
+
model: entry.currentModel,
|
|
79152
|
+
skeleton: true
|
|
79153
|
+
});
|
|
79154
|
+
} catch (cbErr) {
|
|
79155
|
+
log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${cbErr.message}`);
|
|
79156
|
+
}
|
|
79157
|
+
}
|
|
79158
|
+
}
|
|
79125
79159
|
return;
|
|
79160
|
+
}
|
|
79126
79161
|
const buf = Buffer.alloc(stat.size - tail.cursor);
|
|
79127
79162
|
const fd = fs2.openSync(entry.filePath, "r");
|
|
79128
79163
|
try {
|
|
@@ -82723,10 +82758,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
82723
82758
|
}
|
|
82724
82759
|
|
|
82725
82760
|
// ../src/build-info.ts
|
|
82726
|
-
var VERSION = "0.18.
|
|
82727
|
-
var COMMIT_SHA = "
|
|
82728
|
-
var COMMIT_DATE = "2026-07-
|
|
82729
|
-
var LATEST_PR =
|
|
82761
|
+
var VERSION = "0.18.21";
|
|
82762
|
+
var COMMIT_SHA = "c237ff59";
|
|
82763
|
+
var COMMIT_DATE = "2026-07-14T01:11:18+10:00";
|
|
82764
|
+
var LATEST_PR = 3234;
|
|
82730
82765
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
82731
82766
|
|
|
82732
82767
|
// gateway/boot-version.ts
|
|
@@ -84414,6 +84449,7 @@ function applySubagentsSchema(db2) {
|
|
|
84414
84449
|
db2.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
|
|
84415
84450
|
}
|
|
84416
84451
|
db2.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
|
|
84452
|
+
db2.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
|
|
84417
84453
|
}
|
|
84418
84454
|
function mapSubagentRow(row) {
|
|
84419
84455
|
return {
|
|
@@ -99333,7 +99369,7 @@ var didOneTimeSetup = false;
|
|
|
99333
99369
|
process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
|
|
99334
99370
|
`);
|
|
99335
99371
|
},
|
|
99336
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
99372
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
|
|
99337
99373
|
let fleetChatId = "";
|
|
99338
99374
|
try {
|
|
99339
99375
|
const fleets = progressDriver?.peekAllFleets() ?? [];
|
|
@@ -99376,6 +99412,8 @@ var didOneTimeSetup = false;
|
|
|
99376
99412
|
}
|
|
99377
99413
|
if (surface !== "nest")
|
|
99378
99414
|
return;
|
|
99415
|
+
if (skeleton)
|
|
99416
|
+
return;
|
|
99379
99417
|
const turn = currentTurn;
|
|
99380
99418
|
if (turn == null)
|
|
99381
99419
|
return;
|
|
@@ -99435,6 +99473,7 @@ var didOneTimeSetup = false;
|
|
|
99435
99473
|
}
|
|
99436
99474
|
const progressOrigin = resolveSubagentOriginChat(agentId);
|
|
99437
99475
|
const decision = decideSubagentProgress({
|
|
99476
|
+
skeleton: skeleton === true,
|
|
99438
99477
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
99439
99478
|
isBackground,
|
|
99440
99479
|
fleetChatId: progressOrigin?.chatId || fleetChatId,
|
|
@@ -30542,7 +30542,7 @@ void (async () => {
|
|
|
30542
30542
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
30543
30543
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
30544
30544
|
// lives in the `onFinish` block just above.
|
|
30545
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
30545
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
|
|
30546
30546
|
let fleetChatId = ''
|
|
30547
30547
|
try {
|
|
30548
30548
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -30632,6 +30632,15 @@ void (async () => {
|
|
|
30632
30632
|
return
|
|
30633
30633
|
}
|
|
30634
30634
|
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
30635
|
+
// #3233: a skeleton liveness cue carries NO step content by
|
|
30636
|
+
// construction (empty latestSummary/progressLine) — it exists
|
|
30637
|
+
// ONLY to create/keep-alive the orphan worker-feed row handled
|
|
30638
|
+
// just above. Branch EXPLICITLY on the `skeleton` discriminator
|
|
30639
|
+
// rather than inferring "no content" from an empty step line:
|
|
30640
|
+
// a skeleton cue must never nest into the parent's live turn
|
|
30641
|
+
// card (there is nothing to render, and the parent's own card
|
|
30642
|
+
// already owns the turn). Deterministic, controls-in-code.
|
|
30643
|
+
if (skeleton) return
|
|
30635
30644
|
const turn = currentTurn
|
|
30636
30645
|
if (turn == null) return // defensive: 'nest' implies a live turn
|
|
30637
30646
|
// Render regardless of `replyCalled` — a foreground Task
|
|
@@ -30777,8 +30786,17 @@ void (async () => {
|
|
|
30777
30786
|
return
|
|
30778
30787
|
}
|
|
30779
30788
|
|
|
30789
|
+
// #3233: with the worker feed DISABLED, the legacy bucket relay
|
|
30790
|
+
// below injects a synthesized "still working" inbound turn. A
|
|
30791
|
+
// skeleton liveness cue carries an EMPTY latestSummary, so
|
|
30792
|
+
// letting it reach the relay would queue a blank/contentless
|
|
30793
|
+
// progress card. The `skeleton` discriminator is threaded into
|
|
30794
|
+
// the pure decision (gate 1b → 'skeleton-liveness'), which drops
|
|
30795
|
+
// it deterministically (controls-in-code, unit-tested) rather
|
|
30796
|
+
// than an opaque inline return here.
|
|
30780
30797
|
const progressOrigin = resolveSubagentOriginChat(agentId)
|
|
30781
30798
|
const decision = decideSubagentProgress({
|
|
30799
|
+
skeleton: skeleton === true,
|
|
30782
30800
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
30783
30801
|
isBackground,
|
|
30784
30802
|
// Prefer the conversation the Task was dispatched from over
|
|
@@ -176,12 +176,20 @@ export interface SubagentProgressDecisionInput {
|
|
|
176
176
|
* passes it in; the decision returns the new bucket idx on
|
|
177
177
|
* `deliver: true` so the caller can update its tracker. */
|
|
178
178
|
lastBucketIdx: number | null
|
|
179
|
+
/** #3233: true for a growth-independent SKELETON liveness cue (empty
|
|
180
|
+
* `latestSummary`, no step content). It exists ONLY to first-paint /
|
|
181
|
+
* keep-alive the in-message worker-feed row; the legacy bucket relay would
|
|
182
|
+
* turn it into a synthesized "still working" inbound with no content — a
|
|
183
|
+
* blank card. Suppressed deterministically here so the worker-feed-DISABLED
|
|
184
|
+
* path degrades to a no-op rather than a blank envelope. */
|
|
185
|
+
skeleton?: boolean
|
|
179
186
|
/** Deterministic clock for tests. */
|
|
180
187
|
nowMs?: number
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
export type SubagentProgressSkipReason =
|
|
184
191
|
| 'env-disabled'
|
|
192
|
+
| 'skeleton-liveness'
|
|
185
193
|
| 'foreground'
|
|
186
194
|
| 'no-chat'
|
|
187
195
|
| 'bucket-already-fired'
|
|
@@ -199,6 +207,8 @@ export type SubagentProgressDecision =
|
|
|
199
207
|
*
|
|
200
208
|
* Gates, in order:
|
|
201
209
|
* 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
|
|
210
|
+
* 1b. skeleton-liveness (#3233) — a contentless skeleton cue is never
|
|
211
|
+
* relayed as a synthesized inbound (worker-feed row only).
|
|
202
212
|
* 2. foreground — foreground sub-agents stream natively.
|
|
203
213
|
* 3. no-chat — nowhere to deliver.
|
|
204
214
|
* 4. missing-jsonl-id — the dedup key. Without it we'd lose
|
|
@@ -233,6 +243,13 @@ export function decideSubagentProgress(
|
|
|
233
243
|
if (isEnvFlagOn(input.disableEnvValue)) {
|
|
234
244
|
return { deliver: false, reason: 'env-disabled' }
|
|
235
245
|
}
|
|
246
|
+
// #3233: a skeleton liveness cue carries no step content — never relay it as
|
|
247
|
+
// a synthesized progress inbound (that would be a blank card). Its whole job
|
|
248
|
+
// is the in-message worker-feed row; when that surface is off, degrade to a
|
|
249
|
+
// no-op. Checked before bucketing so it can never advance the bucket tracker.
|
|
250
|
+
if (input.skeleton === true) {
|
|
251
|
+
return { deliver: false, reason: 'skeleton-liveness' }
|
|
252
|
+
}
|
|
236
253
|
if (!input.isBackground) {
|
|
237
254
|
return { deliver: false, reason: 'foreground' }
|
|
238
255
|
}
|
|
@@ -277,6 +277,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
|
|
|
277
277
|
// column is guaranteed to exist (either created with the table or added by
|
|
278
278
|
// the migration above).
|
|
279
279
|
db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
|
|
280
|
+
// Same deferred-index rationale as jsonl_agent_id above: parent_agent_id is
|
|
281
|
+
// added by the ALTER migration for pre-existing tables, so its index must be
|
|
282
|
+
// created here (after the column is guaranteed to exist), not in the base SQL.
|
|
283
|
+
// Backs the per-poll child-existence probe in subagent-watcher.ts
|
|
284
|
+
// (`SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1`).
|
|
285
|
+
db.exec('CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)')
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
// ---------------------------------------------------------------------------
|
|
@@ -628,6 +628,12 @@ export interface SubagentWatcherConfig {
|
|
|
628
628
|
* assistant line — the gateway then falls back to the registry's
|
|
629
629
|
* dispatch-time model. */
|
|
630
630
|
model?: string
|
|
631
|
+
/** True for a growth-INDEPENDENT skeleton liveness cue (#3231): fired on a
|
|
632
|
+
* no-growth poll for a running entry so the card can first-paint / stay
|
|
633
|
+
* alive without waiting for JSONL growth. Carries the entry's real state
|
|
634
|
+
* but an EMPTY `latestSummary`/`progressLine` — never fabricated content.
|
|
635
|
+
* Consumers that count real narrative/tool cues must exclude it. */
|
|
636
|
+
skeleton?: boolean
|
|
631
637
|
}) => void
|
|
632
638
|
/** `Date.now` override for tests. */
|
|
633
639
|
now?: () => number
|
|
@@ -1088,6 +1094,9 @@ export function readSubTail(
|
|
|
1088
1094
|
progressLine?: string
|
|
1089
1095
|
/** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
|
|
1090
1096
|
model?: string
|
|
1097
|
+
/** Growth-independent skeleton liveness cue (#3231). See the identically
|
|
1098
|
+
* named field on SubagentWatcherConfig.onProgress. */
|
|
1099
|
+
skeleton?: boolean
|
|
1091
1100
|
}) => void,
|
|
1092
1101
|
): void {
|
|
1093
1102
|
try {
|
|
@@ -1103,7 +1112,83 @@ export function readSubTail(
|
|
|
1103
1112
|
tail.cursor = 0
|
|
1104
1113
|
tail.pendingPartial = ''
|
|
1105
1114
|
}
|
|
1106
|
-
if (stat.size === tail.cursor)
|
|
1115
|
+
if (stat.size === tail.cursor) {
|
|
1116
|
+
// First-paint independence (#3231): the worker card is otherwise driven
|
|
1117
|
+
// ONLY by growth-triggered progress cues below, so a running worker whose
|
|
1118
|
+
// JSONL is not currently growing surfaces NOTHING. That is the ~90-205s
|
|
1119
|
+
// invisible-card bug observed live (a57fbf, 2026-07-13): an async
|
|
1120
|
+
// foreground sub-agent did two Bash calls, then its first tool BLOCKED for
|
|
1121
|
+
// ~99s (no JSONL growth → no cue), and — because its spawning turn had
|
|
1122
|
+
// already ended — no nest and no worker-feed row existed to paint. Its
|
|
1123
|
+
// card did not appear until 205s after registration, on the next growth
|
|
1124
|
+
// event that happened to be classified to the feed. Fire a growth-INDEPENDENT
|
|
1125
|
+
// skeleton liveness cue on every no-growth poll for a live entry so the
|
|
1126
|
+
// gateway can paint (and keep alive) the card from registration onward,
|
|
1127
|
+
// uniformly across ALL spawn origins/nesting levels. The cue carries the
|
|
1128
|
+
// entry's REAL current state (lastTool/toolCount/model) but an EMPTY step
|
|
1129
|
+
// line — no fabricated content: it is inert on the foreground-nest path
|
|
1130
|
+
// (empty child → no-op, the parent's own card owns the live turn) and
|
|
1131
|
+
// creates/refreshes the orphan/background worker-feed row (→ "starting…",
|
|
1132
|
+
// whose first paint the feed's own firstPaintMin + heartbeat then owns).
|
|
1133
|
+
if (onProgress != null && entry.state === 'running' && !entry.historical) {
|
|
1134
|
+
// Child-aware suppression (#3233): the skeleton cue exists to paint a
|
|
1135
|
+
// LEAF worker whose card would otherwise be invisible (the 205s
|
|
1136
|
+
// blackout). A pure-ORCHESTRATOR parent — one that has dispatched a
|
|
1137
|
+
// descendant of its own — must NOT earn a redundant "starting…"
|
|
1138
|
+
// liveness row: the child surfaces its own live row in the same worker
|
|
1139
|
+
// feed, so an extra skeleton row for the parent is pure feed clutter
|
|
1140
|
+
// (fails the no-noise / never-storm bar). The discriminator is
|
|
1141
|
+
// deliberately NOT "0 own tools" — a leaf that registers and BLOCKS on
|
|
1142
|
+
// its very first tool has 0 completed tools and MUST still paint.
|
|
1143
|
+
// Instead, suppress when THIS entry has EVER dispatched a child (any
|
|
1144
|
+
// child registry row keyed by parent_agent_id = this entry's jsonl
|
|
1145
|
+
// agentId; recordNestedSubagentDispatch stamps it). "Ever", not "a
|
|
1146
|
+
// currently-running child": the skeleton cue is only the NO-GROWTH
|
|
1147
|
+
// fallback, so suppressing it for an orchestrator never hides real
|
|
1148
|
+
// work — if the parent does its own tools, those fire real growth
|
|
1149
|
+
// cues and paint the row; if it only orchestrates, its children carry
|
|
1150
|
+
// the liveness. Using "currently running" instead would re-paint a
|
|
1151
|
+
// spurious orchestrator "starting…" the moment its child finished. A
|
|
1152
|
+
// genuine leaf has no child row at all, so it keeps firing the
|
|
1153
|
+
// skeleton cue and paints promptly — the 205s-blackout class is intact.
|
|
1154
|
+
let hasChild = false
|
|
1155
|
+
if (db != null) {
|
|
1156
|
+
try {
|
|
1157
|
+
const kid = db
|
|
1158
|
+
.prepare(
|
|
1159
|
+
'SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1',
|
|
1160
|
+
)
|
|
1161
|
+
.get(entry.agentId)
|
|
1162
|
+
hasChild = kid != null
|
|
1163
|
+
} catch (kidErr) {
|
|
1164
|
+
// Best-effort: an absent/failed linkage read is treated as "leaf"
|
|
1165
|
+
// so we never suppress a genuine blackout paint on a DB hiccup.
|
|
1166
|
+
log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${(kidErr as Error).message}`)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (!hasChild) {
|
|
1170
|
+
try {
|
|
1171
|
+
onProgress({
|
|
1172
|
+
agentId: entry.agentId,
|
|
1173
|
+
description: entry.description,
|
|
1174
|
+
latestSummary: '',
|
|
1175
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
1176
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
1177
|
+
setBucketIdx: (b: number) => {
|
|
1178
|
+
entry.lastProgressBucketIdx = b
|
|
1179
|
+
},
|
|
1180
|
+
lastTool: entry.lastTool,
|
|
1181
|
+
toolCount: entry.toolCount,
|
|
1182
|
+
model: entry.currentModel,
|
|
1183
|
+
skeleton: true,
|
|
1184
|
+
})
|
|
1185
|
+
} catch (cbErr) {
|
|
1186
|
+
log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1107
1192
|
|
|
1108
1193
|
const buf = Buffer.alloc(stat.size - tail.cursor)
|
|
1109
1194
|
const fd = fs.openSync(entry.filePath, 'r')
|
|
@@ -267,6 +267,26 @@ describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
|
|
|
267
267
|
expect(lastChild.text).not.toContain('starting…')
|
|
268
268
|
expect(lastChild.text).toContain('index.ts')
|
|
269
269
|
|
|
270
|
+
// #3233 — DELIBERATE orchestrator-suppression behaviour. The depth-1
|
|
271
|
+
// 'depth-1 orchestrator' parent ran ZERO tools of its own: it only
|
|
272
|
+
// DISPATCHED the nested child. The skeleton first-paint cue (#3231) fires
|
|
273
|
+
// on every no-growth poll, so a naive implementation gives that pure
|
|
274
|
+
// orchestrator its OWN persistent "starting…" worker-feed row — redundant
|
|
275
|
+
// clutter, because the child's row already carries the liveness. The
|
|
276
|
+
// watcher suppresses the skeleton cue for any entry that has dispatched a
|
|
277
|
+
// child (parent_agent_id linkage), so the orchestrator NEVER earns a
|
|
278
|
+
// "starting…" row while its child provides the live signal. This is NOT a
|
|
279
|
+
// "0 own tools" rule (that would re-break the 205s-blackout incident this
|
|
280
|
+
// PR fixes — a leaf that blocks on its FIRST tool also has 0 completed
|
|
281
|
+
// tools and MUST still paint); it keys strictly on the parent/child link.
|
|
282
|
+
const allFeed = [...h.bot.sent, ...h.bot.edits]
|
|
283
|
+
const orchestratorStartingRows = allFeed.filter(
|
|
284
|
+
(m) => m.text.includes('depth-1 orchestrator') && m.text.includes('starting…'),
|
|
285
|
+
)
|
|
286
|
+
expect(orchestratorStartingRows.length).toBe(0)
|
|
287
|
+
// …while the child (a genuine leaf) DID surface real, live tool activity.
|
|
288
|
+
expect(childMsgs.some((m) => m.text.includes('index.ts'))).toBe(true)
|
|
289
|
+
|
|
270
290
|
// More tool activity → climbing tool count, still live.
|
|
271
291
|
h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
|
|
272
292
|
h.advance(1000)
|
|
@@ -264,6 +264,36 @@ describe('decideSubagentProgress', () => {
|
|
|
264
264
|
if (!d.deliver) expect(d.reason).toBe('foreground')
|
|
265
265
|
})
|
|
266
266
|
|
|
267
|
+
// #3233 — worker-feed-DISABLED legacy path: a contentless skeleton liveness
|
|
268
|
+
// cue must NOT be relayed as a synthesized "still working" inbound (that
|
|
269
|
+
// would be a blank card). It degrades to a no-op, deterministically, BEFORE
|
|
270
|
+
// bucketing so it can never advance the bucket tracker.
|
|
271
|
+
it('skeleton liveness cue is dropped (no blank card) even when every other gate would pass', () => {
|
|
272
|
+
// Same input that DELIVERS in the happy-path test above (bucket 1, chat
|
|
273
|
+
// resolves) — only `skeleton` flips it off. Empty summary mirrors the real
|
|
274
|
+
// skeleton cue.
|
|
275
|
+
const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '' }))
|
|
276
|
+
expect(d.deliver).toBe(false)
|
|
277
|
+
if (!d.deliver) expect(d.reason).toBe('skeleton-liveness')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('skeleton suppression fires before bucketing — a background skeleton at bucket>=1 never delivers', () => {
|
|
281
|
+
// ≤1 relay per interval is trivially satisfied: skeleton cues deliver ZERO
|
|
282
|
+
// inbounds regardless of how many no-growth polls fire within a bucket.
|
|
283
|
+
for (const elapsedMs of [7 * 60 * 1000, 8 * 60 * 1000, 9 * 60 * 1000]) {
|
|
284
|
+
const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '', elapsedMs }))
|
|
285
|
+
expect(d.deliver, `elapsedMs=${elapsedMs}`).toBe(false)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('a NON-skeleton cue with identical inputs still delivers (guard is skeleton-scoped, not summary-scoped)', () => {
|
|
290
|
+
// Red-on-regression companion: proves the drop keys on `skeleton`, not on
|
|
291
|
+
// the empty summary — a real tool-only cue (empty prose summary) still
|
|
292
|
+
// delivers, so the guard cannot silently swallow genuine progress.
|
|
293
|
+
const d = decideSubagentProgress(baseInput({ skeleton: false, latestSummary: '' }))
|
|
294
|
+
expect(d.deliver).toBe(true)
|
|
295
|
+
})
|
|
296
|
+
|
|
267
297
|
it('falls back to owner chat when fleet chat is empty', () => {
|
|
268
298
|
const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
|
|
269
299
|
expect(d.deliver).toBe(true)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-paint independence (#3231) regression.
|
|
3
|
+
*
|
|
4
|
+
* LIVE BUG (a57fbf, 2026-07-13): an async foreground sub-agent registered at
|
|
5
|
+
* 13:17:27, did two Bash calls, then its first tool BLOCKED for ~99s (no JSONL
|
|
6
|
+
* growth). Its spawning turn ended at 13:17:48 while it kept running, so there
|
|
7
|
+
* was neither a live parent turn to nest into NOR a worker-feed row to paint —
|
|
8
|
+
* and because the worker card is driven ONLY by growth-triggered onProgress
|
|
9
|
+
* cues, NOTHING surfaced. The card did not appear until 13:20:52, 205s after
|
|
10
|
+
* registration, on the next growth event that happened to route to the feed.
|
|
11
|
+
*
|
|
12
|
+
* The watcher's contract fix: a running, non-historical entry must emit a
|
|
13
|
+
* growth-INDEPENDENT skeleton liveness cue on every no-growth poll — an empty
|
|
14
|
+
* step line carrying the entry's real current state — so the gateway can paint
|
|
15
|
+
* (and keep alive) the card from registration onward, regardless of whether the
|
|
16
|
+
* worker's JSONL is currently growing. The gateway routes it: inert on the
|
|
17
|
+
* foreground-nest path (empty child), row-creating on the orphan/background
|
|
18
|
+
* worker-feed path.
|
|
19
|
+
*
|
|
20
|
+
* These assert OUTCOMES on the onProgress cue stream with an INJECTED clock.
|
|
21
|
+
* A skeleton cue is identified by its explicit `skeleton: true` discriminator
|
|
22
|
+
* (it also carries an empty `latestSummary` and no `progressLine`).
|
|
23
|
+
*
|
|
24
|
+
* RED-ON-REGRESSION: before the fix, readSubTail early-returns on a no-growth
|
|
25
|
+
* poll BEFORE firing any onProgress cue, so ZERO skeleton cues are emitted and
|
|
26
|
+
* every assertion below fails — reproducing the invisible-card window.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
30
|
+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
|
|
31
|
+
import { tmpdir } from 'os'
|
|
32
|
+
import { join } from 'path'
|
|
33
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
34
|
+
|
|
35
|
+
function buildJSONL(...lines: object[]): string {
|
|
36
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
37
|
+
}
|
|
38
|
+
function subAgentUserMsg(promptText: string) {
|
|
39
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
40
|
+
}
|
|
41
|
+
function subAgentToolUse(name: string, id: string) {
|
|
42
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Cue { agentId: string; progressLine?: string; latestSummary: string; elapsedMs: number; skeleton?: boolean }
|
|
46
|
+
|
|
47
|
+
describe('sub-agent card first-paint independence (#3231)', () => {
|
|
48
|
+
let tmpRoot = ''
|
|
49
|
+
const started: Array<ReturnType<typeof startSubagentWatcher>> = []
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
while (started.length) {
|
|
53
|
+
try { started.pop()?.stop() } catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
if (tmpRoot) {
|
|
56
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
57
|
+
tmpRoot = ''
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const RESCAN_MS = 1000
|
|
62
|
+
|
|
63
|
+
function startWatcher(agentDir: string) {
|
|
64
|
+
let currentTime = 100_000
|
|
65
|
+
const cues: Cue[] = []
|
|
66
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
67
|
+
let nextRef = 1
|
|
68
|
+
const watcher = startSubagentWatcher({
|
|
69
|
+
agentDir,
|
|
70
|
+
onFinish: () => {},
|
|
71
|
+
onProgress: ({ agentId, progressLine, latestSummary, elapsedMs, skeleton }) => {
|
|
72
|
+
cues.push({ agentId, progressLine, latestSummary, elapsedMs, skeleton })
|
|
73
|
+
},
|
|
74
|
+
stallThresholdMs: 600_000,
|
|
75
|
+
silentSynthesisStallThresholdMs: 600_000,
|
|
76
|
+
rescanMs: RESCAN_MS,
|
|
77
|
+
now: () => currentTime,
|
|
78
|
+
setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
|
|
79
|
+
clearInterval: (handle) => {
|
|
80
|
+
const { ref } = handle as { ref: number }
|
|
81
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
82
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
83
|
+
},
|
|
84
|
+
setTimeout: () => ({ ref: nextRef++ }),
|
|
85
|
+
clearTimeout: () => {},
|
|
86
|
+
log: () => {},
|
|
87
|
+
})
|
|
88
|
+
started.push(watcher)
|
|
89
|
+
return {
|
|
90
|
+
watcher,
|
|
91
|
+
cues,
|
|
92
|
+
poll: () => intervals[0]?.fn(),
|
|
93
|
+
advance: (ms: number) => { currentTime += ms },
|
|
94
|
+
now: () => currentTime,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const skeletonCues = (cues: Cue[]): Cue[] =>
|
|
99
|
+
cues.filter((c) => c.skeleton === true)
|
|
100
|
+
|
|
101
|
+
function makeSubagentDir(root: string): string {
|
|
102
|
+
const agentDir = join(root, 'agent')
|
|
103
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
104
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
105
|
+
return agentDir
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('emits a growth-independent skeleton cue on the first no-growth poll after registration', () => {
|
|
109
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-'))
|
|
110
|
+
const agentDir = makeSubagentDir(tmpRoot)
|
|
111
|
+
const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-deadbeef.jsonl')
|
|
112
|
+
|
|
113
|
+
// Start the watcher on an empty subagents dir, THEN the worker spawns — the
|
|
114
|
+
// real async Agent-tool path (the JSONL appears post-boot, so the entry is
|
|
115
|
+
// live/non-historical, not a boot-time rediscovery). Only its prompt is on
|
|
116
|
+
// disk, no assistant output yet (it is "thinking"): the pre-content window
|
|
117
|
+
// the user stares at.
|
|
118
|
+
const h = startWatcher(agentDir)
|
|
119
|
+
h.poll() // boot scan over the empty dir
|
|
120
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
121
|
+
h.advance(RESCAN_MS)
|
|
122
|
+
h.poll() // discover + register as a live worker
|
|
123
|
+
|
|
124
|
+
// Next poll: the JSONL has NOT grown. Pre-fix, readSubTail early-returns and
|
|
125
|
+
// NO cue fires — the card is invisible. Post-fix, a skeleton cue surfaces so
|
|
126
|
+
// the gateway can paint the card without waiting for the worker's output.
|
|
127
|
+
h.advance(RESCAN_MS)
|
|
128
|
+
h.poll()
|
|
129
|
+
|
|
130
|
+
const skel = skeletonCues(h.cues)
|
|
131
|
+
expect(skel.length, 'a skeleton cue must fire on a no-growth poll').toBeGreaterThanOrEqual(1)
|
|
132
|
+
// Tight bound vs the ~205s live behaviour: first cue is within a couple polls
|
|
133
|
+
// of registration, NOT minutes.
|
|
134
|
+
expect(skel[0].elapsedMs).toBeLessThanOrEqual(2 * RESCAN_MS)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('keeps surfacing skeleton cues while a worker is silent after an early tool burst (blocked first tool)', () => {
|
|
138
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-silent-'))
|
|
139
|
+
const agentDir = makeSubagentDir(tmpRoot)
|
|
140
|
+
const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-a57fbf00.jsonl')
|
|
141
|
+
|
|
142
|
+
const h = startWatcher(agentDir)
|
|
143
|
+
h.poll() // boot scan over the empty dir
|
|
144
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Debug delegation regression')))
|
|
145
|
+
h.advance(RESCAN_MS)
|
|
146
|
+
h.poll() // discover + register as a live worker
|
|
147
|
+
|
|
148
|
+
// The worker does two quick Bash calls (the a57fbf shape) …
|
|
149
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
150
|
+
h.advance(RESCAN_MS)
|
|
151
|
+
h.poll()
|
|
152
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
|
|
153
|
+
h.advance(RESCAN_MS)
|
|
154
|
+
h.poll()
|
|
155
|
+
|
|
156
|
+
// … then its tool BLOCKS: no JSONL growth for a long stretch (~30 polls).
|
|
157
|
+
// The card must NOT go dark — a skeleton cue must fire on essentially every
|
|
158
|
+
// no-growth poll so the feed row is created/kept-alive and the heartbeat can
|
|
159
|
+
// climb the elapsed. Pre-fix, zero cues fire across the entire silent window.
|
|
160
|
+
const before = h.cues.length
|
|
161
|
+
for (let i = 0; i < 30; i++) {
|
|
162
|
+
h.advance(RESCAN_MS)
|
|
163
|
+
h.poll()
|
|
164
|
+
}
|
|
165
|
+
const duringSilence = h.cues.slice(before)
|
|
166
|
+
const skel = duringSilence.filter((c) => c.skeleton === true)
|
|
167
|
+
expect(skel.length, 'silent worker must keep emitting skeleton cues').toBeGreaterThanOrEqual(20)
|
|
168
|
+
// No skeleton cue ever fabricates content — the step line stays empty.
|
|
169
|
+
for (const c of skel) expect(c.latestSummary).toBe('')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -39,7 +39,7 @@ function subAgentToolUse(name: string, id: string) {
|
|
|
39
39
|
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
interface Cue { progressLine?: string; latestSummary: string }
|
|
42
|
+
interface Cue { progressLine?: string; latestSummary: string; skeleton?: boolean }
|
|
43
43
|
|
|
44
44
|
describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
|
|
45
45
|
let tmpRoot = ''
|
|
@@ -64,8 +64,8 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
|
|
|
64
64
|
const watcher = startSubagentWatcher({
|
|
65
65
|
agentDir,
|
|
66
66
|
onFinish: () => {},
|
|
67
|
-
onProgress: ({ agentId, progressLine, latestSummary }) => {
|
|
68
|
-
cues.push({ agentId, progressLine, latestSummary })
|
|
67
|
+
onProgress: ({ agentId, progressLine, latestSummary, skeleton }) => {
|
|
68
|
+
cues.push({ agentId, progressLine, latestSummary, skeleton })
|
|
69
69
|
},
|
|
70
70
|
stallThresholdMs: 600_000,
|
|
71
71
|
silentSynthesisStallThresholdMs: 600_000,
|
|
@@ -91,8 +91,10 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function narrativeCues(cues: Cue[]): string[] {
|
|
94
|
-
// Narrative cues carry NO progressLine; tool-label cues do.
|
|
95
|
-
|
|
94
|
+
// Narrative cues carry NO progressLine; tool-label cues do. Skeleton
|
|
95
|
+
// liveness cues (#3231) also carry no progressLine but an empty summary —
|
|
96
|
+
// exclude them here so this counts only real narrative content.
|
|
97
|
+
return cues.filter((c) => !c.skeleton && c.progressLine == null).map((c) => c.latestSummary)
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
it('paints a parked opening narration after the flush window with NO tool event', () => {
|
|
@@ -584,9 +584,10 @@ describe('startSubagentWatcher', () => {
|
|
|
584
584
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
585
585
|
const h = startWatcherSync({
|
|
586
586
|
agentDir,
|
|
587
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
587
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
588
588
|
// Narrative ticks carry NO progressLine (tool ticks do); record them.
|
|
589
|
-
|
|
589
|
+
// Skeleton liveness cues (#3231) are not narrative — exclude them.
|
|
590
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
590
591
|
},
|
|
591
592
|
})
|
|
592
593
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
|
|
@@ -614,8 +615,8 @@ describe('startSubagentWatcher', () => {
|
|
|
614
615
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
615
616
|
const h = startWatcherSync({
|
|
616
617
|
agentDir,
|
|
617
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
618
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
618
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
619
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
619
620
|
},
|
|
620
621
|
})
|
|
621
622
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
@@ -647,8 +648,8 @@ describe('startSubagentWatcher', () => {
|
|
|
647
648
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
648
649
|
const h = startWatcherSync({
|
|
649
650
|
agentDir,
|
|
650
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
651
|
-
allCues.push({ progressLine, latestSummary })
|
|
651
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
652
|
+
if (!skeleton) allCues.push({ progressLine, latestSummary })
|
|
652
653
|
},
|
|
653
654
|
})
|
|
654
655
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
@@ -698,8 +699,8 @@ describe('startSubagentWatcher', () => {
|
|
|
698
699
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
699
700
|
const h = startWatcherSync({
|
|
700
701
|
agentDir,
|
|
701
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
702
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
702
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
703
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
703
704
|
},
|
|
704
705
|
})
|
|
705
706
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
@@ -728,8 +729,8 @@ describe('startSubagentWatcher', () => {
|
|
|
728
729
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
729
730
|
const h = startWatcherSync({
|
|
730
731
|
agentDir,
|
|
731
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
732
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
732
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
733
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
733
734
|
},
|
|
734
735
|
})
|
|
735
736
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
@@ -761,8 +762,8 @@ describe('startSubagentWatcher', () => {
|
|
|
761
762
|
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
762
763
|
const h = startWatcherSync({
|
|
763
764
|
agentDir,
|
|
764
|
-
onProgress: ({ progressLine, latestSummary }) => {
|
|
765
|
-
if (progressLine == null) narrativeCues.push(latestSummary)
|
|
765
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
766
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
766
767
|
},
|
|
767
768
|
})
|
|
768
769
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
@@ -87,6 +87,46 @@ describe('worker terminal state is truthful for reaped workers (Residual B)', ()
|
|
|
87
87
|
expect(last).not.toContain('incomplete')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
// #3233 terminal-window race: the skeleton first-paint cue fires on EVERY
|
|
91
|
+
// no-growth poll, so one can land on a row that `finish()` just finalized
|
|
92
|
+
// (watcher poll cadence ≈ seconds). The `finalized` latch must absorb it —
|
|
93
|
+
// a late skeleton `update()` must NOT resurrect a fresh `running` card on an
|
|
94
|
+
// already-done worker. This exercises that path with a skeleton-shaped cue
|
|
95
|
+
// (empty step line, running state) arriving after finish.
|
|
96
|
+
it('a late skeleton update() after finish() is absorbed by the finalized latch (no resurrection)', async () => {
|
|
97
|
+
let clock = 1000
|
|
98
|
+
const { feed, edits } = makeFeed(() => clock)
|
|
99
|
+
await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
|
|
100
|
+
await drain()
|
|
101
|
+
clock = 2000
|
|
102
|
+
await feed.finish('w', {
|
|
103
|
+
description: 'background job',
|
|
104
|
+
lastTool: null,
|
|
105
|
+
toolCount: 5,
|
|
106
|
+
latestSummary: 'the delivered result paragraph',
|
|
107
|
+
elapsedMs: 2000,
|
|
108
|
+
state: 'done',
|
|
109
|
+
})
|
|
110
|
+
await drain()
|
|
111
|
+
const editsAfterFinish = edits.length
|
|
112
|
+
// Late skeleton cue (empty latestSummary, running state) — the shape the
|
|
113
|
+
// watcher emits on a no-growth poll — lands AFTER finalization.
|
|
114
|
+
clock = 2500
|
|
115
|
+
await feed.update('w', 'chat', {
|
|
116
|
+
description: 'background job',
|
|
117
|
+
lastTool: null,
|
|
118
|
+
toolCount: 5,
|
|
119
|
+
latestSummary: '',
|
|
120
|
+
elapsedMs: 2500,
|
|
121
|
+
state: 'running',
|
|
122
|
+
})
|
|
123
|
+
await drain()
|
|
124
|
+
// No further edit, no resurrection: the terminal card stays `done`.
|
|
125
|
+
expect(edits.length).toBe(editsAfterFinish)
|
|
126
|
+
expect(edits[edits.length - 1].text).toContain('done')
|
|
127
|
+
expect(feed.has('w')).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
90
130
|
it('renderWorkerActivity renders the `incomplete` state as a finished card without a fabricated result', () => {
|
|
91
131
|
const card = renderWorkerActivity({
|
|
92
132
|
description: 'background job',
|