feat(KB-150): kill active agent sessions on global pause
- Add settings:updated event to TaskStore with previous/new settings payload - Kill all active executor agent sessions when globalPause transitions false→true - Kill all active triage specification sessions on global pause with clean status reset - Track and dispose active merger session on global pause via onSession callback - Add JSDoc documenting global pause behavior on executor and triage constructors
This commit is contained in:
@@ -59,16 +59,32 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
||||
// AI-powered merge handler (used by the web UI for manual merges).
|
||||
// Wrapped with the shared semaphore so merges count toward the global
|
||||
// concurrency limit alongside triage and execution agents.
|
||||
//
|
||||
// Track the active merge session so it can be killed on global pause.
|
||||
let activeMergeSession: { dispose: () => void } | null = null;
|
||||
|
||||
const rawMerge = (taskId: string) =>
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
pool,
|
||||
usageLimitPauser,
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
|
||||
onSession: (session) => { activeMergeSession = session; },
|
||||
});
|
||||
|
||||
const onMerge = (taskId: string) => semaphore.run(() => rawMerge(taskId), PRIORITY_MERGE);
|
||||
|
||||
// When globalPause transitions from false → true, terminate the active merge session.
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (settings.globalPause && !previous.globalPause) {
|
||||
if (activeMergeSession) {
|
||||
console.log("[auto-merge] Global pause — terminating active merge session");
|
||||
activeMergeSession.dispose();
|
||||
activeMergeSession = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Serialized auto-merge queue ─────────────────────────────────────
|
||||
//
|
||||
// Three paths feed into this queue:
|
||||
|
||||
@@ -974,4 +974,52 @@ describe("TaskStore", () => {
|
||||
expect(updated.columnMovedAt).toBe(originalMovedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings:updated event", () => {
|
||||
it("fires on updateSettings with correct old and new values", async () => {
|
||||
const events: { settings: any; previous: any }[] = [];
|
||||
store.on("settings:updated", (data) => events.push(data));
|
||||
|
||||
await store.updateSettings({ maxConcurrent: 5 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].previous.maxConcurrent).toBe(2); // DEFAULT_SETTINGS value
|
||||
expect(events[0].settings.maxConcurrent).toBe(5);
|
||||
});
|
||||
|
||||
it("includes previous globalPause: false → new globalPause: true when toggled", async () => {
|
||||
const events: { settings: any; previous: any }[] = [];
|
||||
store.on("settings:updated", (data) => events.push(data));
|
||||
|
||||
// Default globalPause is false
|
||||
await store.updateSettings({ globalPause: true });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].previous.globalPause).toBe(false);
|
||||
expect(events[0].settings.globalPause).toBe(true);
|
||||
});
|
||||
|
||||
it("includes previous globalPause: true → new globalPause: false when toggled off", async () => {
|
||||
await store.updateSettings({ globalPause: true });
|
||||
|
||||
const events: { settings: any; previous: any }[] = [];
|
||||
store.on("settings:updated", (data) => events.push(data));
|
||||
|
||||
await store.updateSettings({ globalPause: false });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].previous.globalPause).toBe(true);
|
||||
expect(events[0].settings.globalPause).toBe(false);
|
||||
});
|
||||
|
||||
it("fires on every updateSettings call even when value unchanged", async () => {
|
||||
const events: { settings: any; previous: any }[] = [];
|
||||
store.on("settings:updated", (data) => events.push(data));
|
||||
|
||||
await store.updateSettings({ maxConcurrent: 2 });
|
||||
await store.updateSettings({ maxConcurrent: 2 });
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TaskStoreEvents {
|
||||
"task:updated": [task: Task];
|
||||
"task:deleted": [task: Task];
|
||||
"task:merged": [result: MergeResult];
|
||||
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
||||
"agent:log": [entry: AgentLogEntry];
|
||||
}
|
||||
|
||||
@@ -130,10 +131,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const current = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...current, ...patch };
|
||||
const previous = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...previous, ...patch };
|
||||
config.settings = updated;
|
||||
await this.writeConfig(config);
|
||||
this.emit("settings:updated", { settings: updated, previous });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1176,6 +1176,145 @@ describe("TaskExecutor pause behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor global pause behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("disposes all active sessions when settings:updated fires with globalPause: true", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn1 = vi.fn();
|
||||
const disposeFn2 = vi.fn();
|
||||
let callCount = 0;
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
callCount++;
|
||||
const dispose = callCount === 1 ? disposeFn1 : disposeFn2;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Wait for the global pause to fire; only fire once for first task
|
||||
if (callCount === 2) {
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
}
|
||||
throw new Error("Session terminated");
|
||||
}),
|
||||
dispose,
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Execute two tasks concurrently
|
||||
await Promise.all([
|
||||
executor.execute({
|
||||
id: "KB-001", title: "T1", description: "T", column: "in-progress",
|
||||
dependencies: [], steps: [], currentStep: 0, log: [],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
executor.execute({
|
||||
id: "KB-002", title: "T2", description: "T", column: "in-progress",
|
||||
dependencies: [], steps: [], currentStep: 0, log: [],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Both tasks should be moved to todo (not marked as failed)
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "todo");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("KB-001", { status: "failed" });
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("KB-002", { status: "failed" });
|
||||
});
|
||||
|
||||
it("moves paused tasks to todo (not marked as failed)", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
throw new Error("Session terminated");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any));
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "KB-001", title: "Test", description: "T", column: "in-progress",
|
||||
dependencies: [], steps: [], currentStep: 0, log: [],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("KB-001", { status: "failed" });
|
||||
});
|
||||
|
||||
it("takes no action when globalPause remains false", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Trigger settings:updated but globalPause stays false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
}),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
} as any));
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "KB-001", title: "Test", description: "T", column: "in-progress",
|
||||
dependencies: [], steps: [], currentStep: 0, log: [],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should move to in-review (normal completion), not todo
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("takes no action when globalPause transitions from true to true", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Trigger settings:updated but globalPause is already true
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any));
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "KB-001", title: "Test", description: "T", column: "in-progress",
|
||||
dependencies: [], steps: [], currentStep: 0, log: [],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should move to in-review (normal completion), not todo
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Code review verdict enforcement tests ────────────────────────────
|
||||
|
||||
|
||||
@@ -164,6 +164,17 @@ export class TaskExecutor {
|
||||
/** Tasks that had a dependency added mid-execution (abort + discard worktree). */
|
||||
private depAborted = new Set<string>();
|
||||
|
||||
/**
|
||||
* @param store — Task store instance (also used to listen for events)
|
||||
* @param rootDir — Project root directory
|
||||
* @param options — Executor configuration
|
||||
*
|
||||
* Listens for `task:moved` to auto-execute tasks moved to `in-progress`,
|
||||
* `task:updated` to terminate agent sessions when individual tasks are paused,
|
||||
* and `settings:updated` to terminate **all** active agent sessions when
|
||||
* `globalPause` transitions from `false` to `true` (emergency stop).
|
||||
* Paused tasks are moved back to `todo` rather than marked as `failed`.
|
||||
*/
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private rootDir: string,
|
||||
@@ -186,6 +197,17 @@ export class TaskExecutor {
|
||||
session?.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// When globalPause transitions from false → true, terminate all active agent sessions.
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (settings.globalPause && !previous.globalPause) {
|
||||
for (const [taskId, session] of this.activeSessions) {
|
||||
executorLog.log(`Global pause — terminating agent session for ${taskId}`);
|
||||
this.pausedAborted.add(taskId);
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -533,3 +533,50 @@ describe("aiMergeTask — usage limit detection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — onSession callback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
setupHappyPathExecSync();
|
||||
});
|
||||
|
||||
it("calls onSession with the session object after creation", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: mockSession,
|
||||
} as any);
|
||||
|
||||
const onSession = vi.fn();
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "KB-050", { onSession });
|
||||
|
||||
expect(onSession).toHaveBeenCalledTimes(1);
|
||||
expect(onSession).toHaveBeenCalledWith(mockSession);
|
||||
});
|
||||
|
||||
it("works without onSession callback (backward compatible)", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
// Should not crash without onSession
|
||||
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,10 @@ export interface MergerOptions {
|
||||
pool?: WorktreePool;
|
||||
/** Usage limit pauser — triggers global pause when API limits are detected. */
|
||||
usageLimitPauser?: UsageLimitPauser;
|
||||
/** Called with the agent session immediately after creation. Enables the
|
||||
* caller (e.g. dashboard.ts) to track and externally dispose the session
|
||||
* when a global pause is triggered. */
|
||||
onSession?: (session: { dispose: () => void }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +252,9 @@ export async function aiMergeTask(
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
// Notify the caller so it can track/dispose the session externally (e.g. on global pause)
|
||||
options.onSession?.(session);
|
||||
|
||||
try {
|
||||
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
|
||||
await session.prompt(prompt);
|
||||
|
||||
@@ -17,7 +17,17 @@ import type { TaskDetail } from "@kb/core";
|
||||
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||
|
||||
function createMockStore(tasks: any[] = []) {
|
||||
return {
|
||||
const listeners = new Map<string, Function[]>();
|
||||
const store = {
|
||||
on: vi.fn((event: string, fn: Function) => {
|
||||
const existing = listeners.get(event) || [];
|
||||
existing.push(fn);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
/** Trigger registered listeners for an event (test helper). */
|
||||
_trigger(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) || []) fn(...args);
|
||||
},
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "KB-001",
|
||||
@@ -46,7 +56,8 @@ function createMockStore(tasks: any[] = []) {
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
}),
|
||||
} as any;
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
|
||||
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -1215,3 +1226,116 @@ describe("TriageProcessor usage limit detection", () => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TriageProcessor global pause agent kill", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("disposes active triage sessions when settings:updated fires with globalPause: true", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Trigger global pause while the session is active
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
throw new Error("Session terminated");
|
||||
}),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
} as any));
|
||||
|
||||
const triage = new TriageProcessor(store, "/tmp/test");
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// dispose is called by the global pause listener and again in finally
|
||||
expect(disposeFn).toHaveBeenCalled();
|
||||
// Status should be cleared (not reported as error)
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: null });
|
||||
});
|
||||
|
||||
it("disposed triage tasks have their status cleared and are not reported as error", async () => {
|
||||
const store = createMockStore();
|
||||
const onError = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
throw new Error("Session terminated");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any));
|
||||
|
||||
const triage = new TriageProcessor(store, "/tmp/test", { onSpecifyError: onError });
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// onSpecifyError should NOT be called for global-pause aborted tasks
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
// Status should be cleared
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: null });
|
||||
});
|
||||
|
||||
it("non-pause errors still report via onSpecifyError", async () => {
|
||||
const store = createMockStore();
|
||||
const onError = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
throw new Error("Agent creation failed");
|
||||
});
|
||||
|
||||
const triage = new TriageProcessor(store, "/tmp/test", { onSpecifyError: onError });
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// onSpecifyError should be called for non-pause errors
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "KB-001" }),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,12 +184,36 @@ export class TriageProcessor {
|
||||
private activePollMs: number | null = null;
|
||||
private processing = new Set<string>();
|
||||
private wasGlobalPaused = false;
|
||||
/** Active agent sessions per task, used to terminate on global pause. */
|
||||
private activeSessions = new Map<string, { dispose: () => void }>();
|
||||
/** Tasks that were aborted due to global pause (to avoid reporting as errors). */
|
||||
private globalPauseAborted = new Set<string>();
|
||||
|
||||
/**
|
||||
* @param store — Task store instance (also used to listen for `settings:updated` events)
|
||||
* @param rootDir — Project root directory
|
||||
* @param options — Processor configuration
|
||||
*
|
||||
* Listens for `settings:updated` events: when `globalPause` transitions from
|
||||
* `false` to `true`, all active triage specification sessions are immediately
|
||||
* terminated so the engine acts as a true emergency stop.
|
||||
*/
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private rootDir: string,
|
||||
private options: TriageProcessorOptions = {},
|
||||
) {}
|
||||
) {
|
||||
// When globalPause transitions from false → true, terminate all active triage sessions.
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (settings.globalPause && !previous.globalPause) {
|
||||
for (const [taskId, session] of this.activeSessions) {
|
||||
triageLog.log(`Global pause — terminating triage session for ${taskId}`);
|
||||
this.globalPauseAborted.add(taskId);
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
@@ -305,6 +329,9 @@ export class TriageProcessor {
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
// Register session so the global pause listener can terminate it
|
||||
this.activeSessions.set(task.id, session);
|
||||
|
||||
try {
|
||||
// Read attachment contents for inlining in prompt
|
||||
const { attachmentContents, imageContents } = await readAttachmentContents(
|
||||
@@ -355,6 +382,7 @@ export class TriageProcessor {
|
||||
this.options.onSpecifyComplete?.(task);
|
||||
}
|
||||
} finally {
|
||||
this.activeSessions.delete(task.id);
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
}
|
||||
@@ -370,6 +398,11 @@ export class TriageProcessor {
|
||||
// and specifyTask(). The file is gone, so just log and skip — no point retrying.
|
||||
if (err.code === "ENOENT") {
|
||||
triageLog.log(`${task.id} no longer exists — skipping`);
|
||||
} else if (this.globalPauseAborted.has(task.id)) {
|
||||
// Global pause — clear specifying status without reporting an error
|
||||
this.globalPauseAborted.delete(task.id);
|
||||
triageLog.log(`${task.id} aborted by global pause — clearing status`);
|
||||
await this.store.updateTask(task.id, { status: null }).catch(() => {});
|
||||
} else {
|
||||
// Check if the error is a usage-limit error and trigger global pause
|
||||
if (this.options.usageLimitPauser && isUsageLimitError(err.message)) {
|
||||
|
||||
Reference in New Issue
Block a user