feat: add /stop command to kill running agent process

- /stop, /cancel, /kill aliases all supported
- SIGTERM first (allows graceful AbortController cleanup), SIGKILL after 5s
- Wired through session-commands → GroupQueue.killProcess()
This commit is contained in:
Eyejoker
2026-03-29 18:44:37 +09:00
parent d01f98bd61
commit 2ec90fe086
4 changed files with 53 additions and 1 deletions

View File

@@ -503,6 +503,43 @@ export class GroupQueue {
} }
} }
/**
* Immediately kill the running agent process for a group.
* Returns true if a process was killed, false if nothing was running.
*/
killProcess(groupJid: string): boolean {
const state = this.getGroup(groupJid);
const proc = state.process;
if (!proc || !this.isProcessAlive(proc)) {
return false;
}
logger.info(
{
groupJid,
runId: state.currentRunId,
processName: state.processName,
},
'Killing agent process via /stop command',
);
try {
// SIGTERM allows the runner to call AbortController.abort() for graceful cleanup.
// Falls back to SIGKILL after 5 seconds if the process doesn't exit.
proc.kill('SIGTERM');
setTimeout(() => {
if (this.isProcessAlive(proc)) {
try {
proc.kill('SIGKILL');
} catch {
/* already dead */
}
}
}, 5000);
} catch {
/* already dead */
}
return true;
}
private async runForGroup( private async runForGroup(
groupJid: string, groupJid: string,
reason: 'messages' | 'drain', reason: 'messages' | 'drain',

View File

@@ -581,6 +581,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}); });
return formatRoomReviewReadyMessage(result); return formatRoomReviewReadyMessage(result);
}, },
killProcess: () => deps.queue.killProcess(chatJid),
}, },
}); });
if (cmdResult.handled) return cmdResult.success; if (cmdResult.handled) return cmdResult.success;
@@ -917,7 +918,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
} }
} }
} }
}; };

View File

@@ -176,6 +176,7 @@ function makeDeps(
isAdminSender: vi.fn().mockReturnValue(false), isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true), canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
killProcess: vi.fn().mockReturnValue(false),
...overrides, ...overrides,
}; };
} }

View File

@@ -34,6 +34,7 @@ export function extractSessionCommand(
if (text === '/compact') return '/compact'; if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear'; if (text === '/clear') return '/clear';
if (text === '/review' || text === '/review-ready') return '/review'; if (text === '/review' || text === '/review-ready') return '/review';
if (text === '/stop' || text === '/cancel' || text === '/kill') return '/stop';
return null; return null;
} }
@@ -79,6 +80,8 @@ export interface SessionCommandDeps {
/** Whether the denied sender would normally be allowed to interact (for denial messages). */ /** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean; canSenderInteract: (msg: NewMessage) => boolean;
markReviewReady: () => Promise<string | null>; markReviewReady: () => Promise<string | null>;
/** Kill the currently running agent process for this group. Returns true if a process was killed. */
killProcess: () => boolean;
} }
function resultToText(result: string | object | null | undefined): string { function resultToText(result: string | object | null | undefined): string {
@@ -173,6 +176,17 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true }; return { handled: true, success: true };
} }
if (command === '/stop') {
const killed = deps.killProcess();
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
killed
? 'Agent stopped.'
: 'No agent is currently running in this room.',
);
return { handled: true, success: true };
}
const cmdIndex = missedMessages.indexOf(cmdMsg); const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex); const preCompactMsgs = missedMessages.slice(0, cmdIndex);