feat(hooks): inject background tasks and cron jobs status into Stop/SubagentStop hook payloads

Extend StopInput and SubagentStopInput with background_tasks and crons fields,
and wire existing BackgroundTaskRegistry and CronScheduler snapshots into
fireStopEvent/fireSubagentStopEvent. This enables hook scripts to observe
async work state at agent stop time for status reports, cleanup logic, and
monitoring.

Fields are always present (empty arrays when no active tasks/crons). Data
collection is non-blocking via try/catch to not delay stop hook response.

Closes #6529

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
DennisYu07 2026-07-08 17:12:07 +08:00
parent 8296ce9e54
commit b092b428ef
4 changed files with 201 additions and 0 deletions

View file

@ -56,6 +56,8 @@ function makeStopInput(lastAssistantText: string): StopInput {
timestamp: new Date().toISOString(),
stop_hook_active: true,
last_assistant_message: lastAssistantText,
background_tasks: [],
crons: [],
};
}

View file

@ -410,6 +410,125 @@ describe('HookEventHandler', () => {
expect(result.success).toBe(true);
expect(result.finalOutput).toBeUndefined();
});
it('should inject background_tasks and crons into Stop input', async () => {
const mockPlan = createMockExecutionPlan([
{
type: HookType.Command,
command: 'echo test',
source: HooksConfigSource.Project,
},
]);
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
createMockAggregatedResult(true),
);
const mockRegistry = {
getAll: vi.fn().mockReturnValue([
{
id: 'bg-1',
status: 'running',
subagentType: 'Explorer',
startTime: 1720000000000,
description: 'test task',
},
]),
};
const mockScheduler = {
list: vi.fn().mockReturnValue([
{
id: 'cron-1',
cronExpr: '0 */2 * * *',
prompt: 'check status',
recurring: true,
fireAtMs: 1720007200000,
lastFiredAt: 1720000000000,
},
]),
};
const configWithMocks = mockConfig as unknown as {
getBackgroundTaskRegistry: () => typeof mockRegistry;
getCronScheduler: () => typeof mockScheduler;
};
configWithMocks.getBackgroundTaskRegistry = () => mockRegistry;
configWithMocks.getCronScheduler = () => mockScheduler;
// Re-create handler with updated config mocks
const handler = new HookEventHandler(
mockConfig,
mockHookPlanner,
mockHookRunner,
mockHookAggregator,
mockSessionHooksManager,
);
await handler.fireStopEvent(true, 'msg');
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
.calls;
const input = mockCalls[0][2] as {
background_tasks: Array<{
id: string;
status: string;
agent_type: string;
}>;
crons: Array<{ id: string; schedule: string; prompt: string }>;
};
expect(input.background_tasks).toHaveLength(1);
expect(input.background_tasks[0].id).toBe('bg-1');
expect(input.background_tasks[0].status).toBe('running');
expect(input.background_tasks[0].agent_type).toBe('Explorer');
expect(input.crons).toHaveLength(1);
expect(input.crons[0].id).toBe('cron-1');
expect(input.crons[0].schedule).toBe('0 */2 * * *');
expect(input.crons[0].prompt).toBe('check status');
});
it('should return empty arrays when registry/scheduler unavailable', async () => {
const mockPlan = createMockExecutionPlan([
{
type: HookType.Command,
command: 'echo test',
source: HooksConfigSource.Project,
},
]);
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
createMockAggregatedResult(true),
);
// Config without registry/scheduler methods
const bareConfig = {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
getWorkingDir: vi.fn().mockReturnValue('/test/cwd'),
} as unknown as Config;
const handler = new HookEventHandler(
bareConfig,
mockHookPlanner,
mockHookRunner,
mockHookAggregator,
mockSessionHooksManager,
);
await handler.fireStopEvent(true, 'msg');
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
.calls;
const input = mockCalls[0][2] as {
background_tasks: unknown[];
crons: unknown[];
};
expect(input.background_tasks).toEqual([]);
expect(input.crons).toEqual([]);
});
});
describe('fireSessionStartEvent', () => {

View file

@ -52,11 +52,14 @@ import type {
InstructionsLoadedInput,
InstructionMemoryType,
InstructionLoadReason,
BackgroundTaskInfo,
CronJobInfo,
} from './types.js';
import { HookPhase, PermissionMode } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { logHookCall } from '../telemetry/loggers.js';
import { HookCallEvent } from '../telemetry/types.js';
import type { CronJob } from '../services/cronScheduler.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -102,6 +105,50 @@ export class HookEventHandler {
return this.messagesProvider;
}
/**
* Snapshot of current background tasks for hook payloads.
* Non-blocking: reads registry state synchronously.
*/
private getBackgroundTaskSnapshot(): BackgroundTaskInfo[] {
try {
const registry = this.config.getBackgroundTaskRegistry();
return registry.getAll().map((task) => ({
id: task.id,
status: task.status,
agent_type: task.subagentType ?? 'unknown',
started_at: new Date(task.startTime).toISOString(),
description: task.description,
}));
} catch {
return [];
}
}
/**
* Snapshot of current cron jobs for hook payloads.
* Non-blocking: reads scheduler state synchronously.
*/
private getCronJobSnapshot(): CronJobInfo[] {
try {
const scheduler = this.config.getCronScheduler();
return scheduler.list().map((job: CronJob) => ({
id: job.id,
schedule: job.cronExpr,
prompt: job.prompt,
recurring: job.recurring,
next_run: job.fireAtMs
? new Date(job.fireAtMs).toISOString()
: undefined,
last_run: job.lastFiredAt
? new Date(job.lastFiredAt).toISOString()
: undefined,
enabled: true,
}));
} catch {
return [];
}
}
/**
* Fire a UserPromptSubmit event
* Called by handleHookExecutionRequest - executes hooks directly
@ -196,6 +243,8 @@ export class HookEventHandler {
...this.createBaseInput(HookEventName.Stop),
stop_hook_active: stopHookActive,
last_assistant_message: lastAssistantMessage,
background_tasks: this.getBackgroundTaskSnapshot(),
crons: this.getCronJobSnapshot(),
...contextUsage,
};
@ -546,6 +595,8 @@ export class HookEventHandler {
agent_type: agentType,
agent_transcript_path: agentTranscriptPath,
last_assistant_message: lastAssistantMessage,
background_tasks: this.getBackgroundTaskSnapshot(),
crons: this.getCronJobSnapshot(),
};
// Pass agentType as context for matcher filtering

View file

@ -5,6 +5,7 @@
*/
import type { ChildProcess } from 'child_process';
import type { ToolArtifact } from '../tools/tools.js';
import type { TaskStatus } from '../agents/tasks/types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -923,12 +924,38 @@ export interface ContextUsageData {
input_tokens: number;
}
/**
* Background task info for hook payloads
*/
export interface BackgroundTaskInfo {
id: string;
status: TaskStatus;
agent_type: string;
started_at: string;
description?: string;
}
/**
* Cron job info for hook payloads
*/
export interface CronJobInfo {
id: string;
schedule: string;
prompt: string;
recurring: boolean;
next_run?: string;
last_run?: string;
enabled: boolean;
}
/**
* Stop hook input
*/
export interface StopInput extends HookInput, Partial<ContextUsageData> {
stop_hook_active: boolean;
last_assistant_message: string;
background_tasks: BackgroundTaskInfo[];
crons: CronJobInfo[];
}
/**
@ -1102,6 +1129,8 @@ export interface SubagentStopInput extends HookInput {
agent_type: AgentType | string;
agent_transcript_path: string;
last_assistant_message: string;
background_tasks: BackgroundTaskInfo[];
crons: CronJobInfo[];
}
/**