Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions packages/core/src/fleet-manager/job-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,19 +509,48 @@ export class JobControl {
});

// Execute after_run hooks (run for all events)
if (agent.hooks.after_run && agent.hooks.after_run.length > 0) {
logger.debug(`Executing ${agent.hooks.after_run.length} after_run hook(s)`);
const afterRunResult = await hookExecutor.executeHooks(agent.hooks, context, "after_run");
// Skip Discord hooks when the job was triggered from Discord, since the
// Discord manager already streams output to the channel in real-time.
const filteredAfterRun =
context.triggerType === "discord"
? (agent.hooks.after_run ?? []).filter((h) => h.type !== "discord")
: agent.hooks.after_run;
if (filteredAfterRun && filteredAfterRun.length > 0) {
if (filteredAfterRun.length !== (agent.hooks.after_run ?? []).length) {
logger.debug(
"Skipping Discord after_run hook(s) for Discord-triggered job to prevent duplicates",
);
}
logger.debug(`Executing ${filteredAfterRun.length} after_run hook(s)`);
const afterRunResult = await hookExecutor.executeHooks(
{ ...agent.hooks, after_run: filteredAfterRun },
context,
"after_run",
);

if (afterRunResult.shouldFailJob) {
logger.warn(`Hook failure with continue_on_error=false detected for job ${jobMetadata.id}`);
}
}

// Execute on_error hooks (only for failed events)
if (event === "failed" && agent.hooks.on_error && agent.hooks.on_error.length > 0) {
logger.debug(`Executing ${agent.hooks.on_error.length} on_error hook(s)`);
const onErrorResult = await hookExecutor.executeHooks(agent.hooks, context, "on_error");
// Also skip Discord hooks for Discord-triggered jobs here
const filteredOnError =
context.triggerType === "discord"
? (agent.hooks.on_error ?? []).filter((h) => h.type !== "discord")
: agent.hooks.on_error;
if (event === "failed" && filteredOnError && filteredOnError.length > 0) {
if (filteredOnError.length !== (agent.hooks.on_error ?? []).length) {
logger.debug(
"Skipping Discord on_error hook(s) for Discord-triggered job to prevent duplicates",
);
}
logger.debug(`Executing ${filteredOnError.length} on_error hook(s)`);
const onErrorResult = await hookExecutor.executeHooks(
{ ...agent.hooks, on_error: filteredOnError },
context,
"on_error",
);

if (onErrorResult.shouldFailJob) {
logger.warn(
Expand Down Expand Up @@ -556,6 +585,7 @@ export class JobControl {

return {
event,
triggerType: jobMetadata.trigger_type,
job: {
id: jobMetadata.id,
agentId: agent.qualifiedName,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
WebhookHookConfig,
WebhookHookConfigInput,
} from "../config/schema.js";
import type { TriggerType } from "../state/schemas/job-metadata.js";

// Re-export for convenience within the hooks module
// Export input types (used by tests and runners)
Expand Down Expand Up @@ -149,6 +150,15 @@ export interface HookContext {
name?: string;
};

/**
* How the job was triggered (e.g. "manual", "schedule", "discord", "slack")
*
* Used to prevent duplicate notifications - for example, Discord hooks are
* skipped when triggerType is "discord" because the Discord manager already
* streams output to the channel in real-time.
*/
triggerType?: TriggerType;

/**
* Agent-provided metadata (from metadata.json or configured metadata_file)
*
Expand Down
Loading