mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
8296ce9e54
commit
b092b428ef
4 changed files with 201 additions and 0 deletions
|
|
@ -56,6 +56,8 @@ function makeStopInput(lastAssistantText: string): StopInput {
|
|||
timestamp: new Date().toISOString(),
|
||||
stop_hook_active: true,
|
||||
last_assistant_message: lastAssistantText,
|
||||
background_tasks: [],
|
||||
crons: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue