Skip to content

Commit 5a26748

Browse files
authored
fix(commitments): isolate extraction batches by agent (#104426)
* fix(commitments): isolate extraction batches by agent * chore: leave release notes to release workflow
1 parent cba9ffb commit 5a26748

2 files changed

Lines changed: 120 additions & 5 deletions

File tree

src/commitments/runtime.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,43 @@ describe("commitment extraction runtime", () => {
190190
expect(store.commitments[0]).not.toHaveProperty("sourceAssistantText");
191191
});
192192

193+
it("partitions extraction batches by agent", async () => {
194+
const cfg = await createConfig();
195+
const extractBatch = vi.fn(async (_params: { items: CommitmentExtractionItem[] }) => ({
196+
candidates: [],
197+
}));
198+
configureCommitmentExtractionRuntime({
199+
forceInTests: true,
200+
extractBatch,
201+
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
202+
clearTimer: () => undefined,
203+
});
204+
205+
for (const [index, agentId] of ["alpha", "beta", "alpha", "beta"].entries()) {
206+
expect(
207+
enqueueCommitmentExtraction({
208+
cfg,
209+
nowMs: nowMs + index,
210+
agentId,
211+
sessionKey: `agent:${agentId}:telegram:user-1`,
212+
channel: "telegram",
213+
sourceMessageId: `m${index}`,
214+
userText: `Commitment candidate ${index}`,
215+
assistantText: "I will follow up.",
216+
}),
217+
).toBe(true);
218+
}
219+
220+
await expect(drainCommitmentExtractionQueue()).resolves.toBe(4);
221+
expect(extractBatch).toHaveBeenCalledTimes(2);
222+
expect(
223+
extractBatch.mock.calls.map(([params]) => params.items.map((item) => item.agentId)),
224+
).toEqual([
225+
["alpha", "alpha"],
226+
["beta", "beta"],
227+
]);
228+
});
229+
193230
it("uses the configured agent model for the hidden extractor run", async () => {
194231
const cfg = await createConfig();
195232
cfg.agents = {
@@ -568,6 +605,58 @@ describe("commitment extraction runtime", () => {
568605
expect(extractBatch).toHaveBeenCalledTimes(1);
569606
});
570607

608+
it("keeps other agents queued after a terminal extraction failure", async () => {
609+
const cfg = await createConfig();
610+
const scheduled: Array<() => void> = [];
611+
const extractBatch = vi.fn(async ({ items }: { items: CommitmentExtractionItem[] }) => {
612+
if (items[0]?.agentId === "alpha") {
613+
throw new Error('No API key found for provider "openai".');
614+
}
615+
return { candidates: [] };
616+
});
617+
configureCommitmentExtractionRuntime({
618+
forceInTests: true,
619+
extractBatch,
620+
setTimer: (callback) => {
621+
scheduled.push(callback);
622+
return { unref() {} } as ReturnType<typeof setTimeout>;
623+
},
624+
clearTimer: () => undefined,
625+
});
626+
627+
for (const agentId of ["alpha", "beta"]) {
628+
expect(
629+
enqueueCommitmentExtraction({
630+
cfg,
631+
nowMs,
632+
agentId,
633+
sessionKey: `agent:${agentId}:telegram:user-1`,
634+
channel: "telegram",
635+
userText: "I have an interview tomorrow.",
636+
assistantText: "Good luck.",
637+
}),
638+
).toBe(true);
639+
}
640+
641+
expect(scheduled).toHaveLength(1);
642+
scheduled[0]?.();
643+
await vi.waitFor(() => {
644+
expect(extractBatch).toHaveBeenCalledTimes(1);
645+
});
646+
await vi.waitFor(() => {
647+
expect(scheduled).toHaveLength(2);
648+
});
649+
650+
scheduled[1]?.();
651+
await vi.waitFor(() => {
652+
expect(extractBatch).toHaveBeenCalledTimes(2);
653+
});
654+
expect(extractBatch.mock.calls.map(([params]) => params.items[0]?.agentId)).toEqual([
655+
"alpha",
656+
"beta",
657+
]);
658+
});
659+
571660
it("schedules a retry when a non-terminal failure leaves the queue full", async () => {
572661
const cfg = await createConfig();
573662
const scheduled: Array<() => void> = [];

src/commitments/runtime.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,8 @@ function clearTimer(handle: TimerHandle): void {
7878
}
7979

8080
// Single-slot debounce: schedule one drain unless one is already pending. Shared
81-
// by enqueue (new work), the overflow branch, and the drain's non-terminal
82-
// failure path (so a batch restored after a timer-fired failure still gets
83-
// retried even when no later enqueue arrives).
81+
// by enqueue (new work), the overflow branch, and drain failure paths so queued
82+
// work still progresses after a timer-fired extraction failure.
8483
function scheduleDrainSoon(debounceMs: number): void {
8584
if (timer) {
8685
return;
@@ -294,6 +293,24 @@ async function hydrateBatch(
294293
);
295294
}
296295

296+
function takeAgentBatch(
297+
agentId: string,
298+
maxItems: number,
299+
): Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }> {
300+
const batch = [];
301+
for (let index = 0; index < queue.length && batch.length < maxItems;) {
302+
if (queue[index]?.agentId !== agentId) {
303+
index += 1;
304+
continue;
305+
}
306+
const [item] = queue.splice(index, 1);
307+
if (item) {
308+
batch.push(item);
309+
}
310+
}
311+
return batch;
312+
}
313+
297314
/** Drains queued extraction work in batches and returns processed item count. */
298315
export async function drainCommitmentExtractionQueue(): Promise<number> {
299316
if (draining) {
@@ -303,9 +320,15 @@ export async function drainCommitmentExtractionQueue(): Promise<number> {
303320
try {
304321
let processed = 0;
305322
while (queue.length > 0) {
306-
const firstCfg = queue[0]?.cfg;
323+
const first = queue[0];
324+
if (!first) {
325+
break;
326+
}
327+
const firstCfg = first.cfg;
307328
const resolved = resolveCommitmentsConfig(firstCfg);
308-
const batch = queue.splice(0, resolved.extraction.batchMaxItems);
329+
// Extraction inherits the first item's model, credentials, workspace, and
330+
// session file. Keep every prompt and failure policy scoped to that agent.
331+
const batch = takeAgentBatch(first.agentId, resolved.extraction.batchMaxItems);
309332
const items = await hydrateBatch(batch);
310333
const extractor = runtime.extractBatch ?? defaultExtractBatch;
311334
let result: CommitmentExtractionBatchResult;
@@ -319,6 +342,9 @@ export async function drainCommitmentExtractionQueue(): Promise<number> {
319342
Date.now(),
320343
items[0]?.nowMs ?? Date.now(),
321344
);
345+
if (queue.length > 0) {
346+
scheduleDrainSoon(resolved.extraction.debounceMs);
347+
}
322348
} else {
323349
// Non-terminal failure (e.g. transient model/network error): the batch
324350
// was already spliced out, so restore it to the front in original order.

0 commit comments

Comments
 (0)