Skip to content

Commit 44c6ad7

Browse files
TurboTheTurtleclawsweeper[bot]Takhoffman
authored
fix(subagents): collect unresolved announce batches (#83701)
Summary: - The PR changes collect-mode follow-up queue routing so unresolved-origin items can batch with a single resolved route and later compatible items can resume batching after a true cross-channel drain. - Reproducibility: yes. at source level: current main treats unkeyed-plus-same-keyed queue items as cross-chan ... failing path is directly visible in `src/utils/queue-helpers.ts` and `src/auto-reply/reply/queue/drain.ts`. Automerge notes: - PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into maint-83701-20260518 Validation: - ClawSweeper review passed for head e6ad029. - Required merge gates passed before the squash merge. Prepared head SHA: e6ad029 Review: #83701 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 3e6f749 commit 44c6ad7

5 files changed

Lines changed: 132 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646

4747
### Fixes
4848

49+
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
4950
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
5051
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
5152
- Browser: keep a profile `cdpPort` when its `cdpUrl` omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.

src/auto-reply/reply/queue.collect.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,110 @@ describe("followup queue collect routing", () => {
9696
expect(calls[0]?.originatingTo).toBe("channel:A");
9797
});
9898

99+
it("collects compatible items after one cross-channel drain", async () => {
100+
const key = `test-collect-after-cross-${Date.now()}`;
101+
const calls: FollowupRun[] = [];
102+
const done = createDeferred<void>();
103+
const runFollowup = async (run: FollowupRun) => {
104+
calls.push(run);
105+
if (calls.length >= 2) {
106+
done.resolve();
107+
}
108+
};
109+
const settings: QueueSettings = {
110+
mode: "collect",
111+
debounceMs: 0,
112+
cap: 50,
113+
dropPolicy: "summarize",
114+
};
115+
116+
enqueueFollowupRun(
117+
key,
118+
createRun({
119+
prompt: "first route",
120+
originatingChannel: "slack",
121+
originatingTo: "channel:A",
122+
}),
123+
settings,
124+
);
125+
enqueueFollowupRun(
126+
key,
127+
createRun({
128+
prompt: "second route one",
129+
originatingChannel: "slack",
130+
originatingTo: "channel:B",
131+
}),
132+
settings,
133+
);
134+
enqueueFollowupRun(
135+
key,
136+
createRun({
137+
prompt: "second route two",
138+
originatingChannel: "slack",
139+
originatingTo: "channel:B",
140+
}),
141+
settings,
142+
);
143+
144+
scheduleFollowupDrain(key, runFollowup);
145+
await done.promise;
146+
147+
expect(calls).toHaveLength(2);
148+
expect(calls[0]?.prompt).toBe("first route");
149+
expect(calls[1]?.prompt).toContain("[Queued messages while agent was busy]");
150+
expect(calls[1]?.prompt).toContain("Queued #1\nsecond route one");
151+
expect(calls[1]?.prompt).toContain("Queued #2\nsecond route two");
152+
expect(calls[1]?.originatingChannel).toBe("slack");
153+
expect(calls[1]?.originatingTo).toBe("channel:B");
154+
});
155+
156+
it("collects unresolved-origin items with an otherwise single route", async () => {
157+
const key = `test-collect-unresolved-origin-${Date.now()}`;
158+
const calls: FollowupRun[] = [];
159+
const done = createDeferred<void>();
160+
const runFollowup = async (run: FollowupRun) => {
161+
calls.push(run);
162+
done.resolve();
163+
};
164+
const settings: QueueSettings = {
165+
mode: "collect",
166+
debounceMs: 0,
167+
cap: 50,
168+
dropPolicy: "summarize",
169+
};
170+
171+
enqueueFollowupRun(key, createRun({ prompt: "unresolved origin" }), settings);
172+
enqueueFollowupRun(
173+
key,
174+
createRun({
175+
prompt: "keyed one",
176+
originatingChannel: "slack",
177+
originatingTo: "channel:B",
178+
}),
179+
settings,
180+
);
181+
enqueueFollowupRun(
182+
key,
183+
createRun({
184+
prompt: "keyed two",
185+
originatingChannel: "slack",
186+
originatingTo: "channel:B",
187+
}),
188+
settings,
189+
);
190+
191+
scheduleFollowupDrain(key, runFollowup);
192+
await done.promise;
193+
194+
expect(calls).toHaveLength(1);
195+
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
196+
expect(calls[0]?.prompt).toContain("Queued #1\nunresolved origin");
197+
expect(calls[0]?.prompt).toContain("Queued #2\nkeyed one");
198+
expect(calls[0]?.prompt).toContain("Queued #3\nkeyed two");
199+
expect(calls[0]?.originatingChannel).toBe("slack");
200+
expect(calls[0]?.originatingTo).toBe("channel:B");
201+
});
202+
99203
it("collects ordinary user-request followups with current turn kind", async () => {
100204
const key = `test-collect-user-request-kind-${Date.now()}`;
101205
const calls: FollowupRun[] = [];

src/auto-reply/reply/queue/drain.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,9 @@ export function scheduleFollowupDrain(
332332
const isCrossChannel =
333333
hasCrossChannelItems(queue.items, resolveCrossChannelKey) ||
334334
queue.items.some(hasRuntimeOnlyFollowupMetadata);
335+
if (collectState.forceIndividualCollect && !isCrossChannel && queue.items.length > 1) {
336+
collectState.forceIndividualCollect = false;
337+
}
335338

336339
const collectDrainResult = await drainCollectQueueStep({
337340
collectState,

src/utils/queue-helpers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildQueueSummaryPrompt,
55
clearQueueSummaryState,
66
drainCollectItemIfNeeded,
7+
hasCrossChannelItems,
78
previewQueueSummaryPrompt,
89
} from "./queue-helpers.js";
910

@@ -167,3 +168,26 @@ describe("drainCollectItemIfNeeded", () => {
167168
expect(forced).toBe(true);
168169
});
169170
});
171+
172+
describe("hasCrossChannelItems", () => {
173+
it("lets unresolved items join an otherwise single keyed route", () => {
174+
const items = [
175+
{ id: "unresolved" },
176+
{ id: "first", key: "slack:channel:A" },
177+
{ id: "second", key: "slack:channel:A" },
178+
];
179+
180+
expect(hasCrossChannelItems(items, (item) => ({ key: item.key }))).toBe(false);
181+
});
182+
183+
it("still treats distinct keyed routes and explicit cross items as cross-channel", () => {
184+
expect(
185+
hasCrossChannelItems([{ key: "slack:channel:A" }, { key: "slack:channel:B" }], (item) => ({
186+
key: item.key,
187+
})),
188+
).toBe(true);
189+
expect(
190+
hasCrossChannelItems([{ key: "slack:channel:A" }, { cross: true }], (item) => item),
191+
).toBe(true);
192+
});
193+
});

src/utils/queue-helpers.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,25 +237,17 @@ export function hasCrossChannelItems<T>(
237237
resolveKey: (item: T) => { key?: string; cross?: boolean },
238238
): boolean {
239239
const keys = new Set<string>();
240-
let hasUnkeyed = false;
241240

242241
for (const item of items) {
243242
const resolved = resolveKey(item);
244243
if (resolved.cross) {
245244
return true;
246245
}
247246
if (!resolved.key) {
248-
hasUnkeyed = true;
249247
continue;
250248
}
251249
keys.add(resolved.key);
252250
}
253251

254-
if (keys.size === 0) {
255-
return false;
256-
}
257-
if (hasUnkeyed) {
258-
return true;
259-
}
260252
return keys.size > 1;
261253
}

0 commit comments

Comments
 (0)