Continue runtime and scheduler cleanup
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
editFormattedTrackedChannelMessage,
|
editFormattedTrackedChannelMessage,
|
||||||
|
isTerminalStatusMessage,
|
||||||
sendFormattedChannelMessage,
|
sendFormattedChannelMessage,
|
||||||
sendFormattedTrackedChannelMessage,
|
sendFormattedTrackedChannelMessage,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
@@ -21,6 +22,16 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('index scheduler messaging helpers', () => {
|
describe('index scheduler messaging helpers', () => {
|
||||||
|
it('uses the paired verdict parser to detect terminal status messages', () => {
|
||||||
|
expect(isTerminalStatusMessage('DONE\nfinished')).toBe(true);
|
||||||
|
expect(isTerminalStatusMessage('**DONE_WITH_CONCERNS**\nneed follow-up')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isTerminalStatusMessage('Approved.\nlooks good')).toBe(true);
|
||||||
|
expect(isTerminalStatusMessage('LGTM\nship it')).toBe(true);
|
||||||
|
expect(isTerminalStatusMessage('random prose only')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('sends formatted tracked messages through sendAndTrack', async () => {
|
it('sends formatted tracked messages through sendAndTrack', async () => {
|
||||||
const sendAndTrack = vi.fn(async () => 'msg-123');
|
const sendAndTrack = vi.fn(async () => 'msg-123');
|
||||||
const channel = makeChannel({ sendAndTrack });
|
const channel = makeChannel({ sendAndTrack });
|
||||||
|
|||||||
@@ -75,12 +75,10 @@ import {
|
|||||||
hasAvailableClaudeToken,
|
hasAvailableClaudeToken,
|
||||||
initTokenRotation,
|
initTokenRotation,
|
||||||
} from './token-rotation.js';
|
} from './token-rotation.js';
|
||||||
|
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
|
||||||
|
|
||||||
function isTerminalStatusMessage(text: string): boolean {
|
export function isTerminalStatusMessage(text: string): boolean {
|
||||||
const trimmed = text.trimStart();
|
return parseVisibleVerdict(text) !== 'continue';
|
||||||
return /^(?:\*\*)?(DONE(?:_WITH_CONCERNS)?|BLOCKED|NEEDS_CONTEXT)\b/.test(
|
|
||||||
trimmed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
import {
|
import {
|
||||||
startTokenRefreshLoop,
|
startTokenRefreshLoop,
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import { isSessionCommandSenderAllowed } from './config.js';
|
import { isSessionCommandSenderAllowed } from './config.js';
|
||||||
import { hasReviewerLease } from './service-routing.js';
|
import { hasReviewerLease } from './service-routing.js';
|
||||||
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||||
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
@@ -32,24 +32,6 @@ import type {
|
|||||||
|
|
||||||
type RuntimeLog = Pick<typeof logger, 'info' | 'debug'>;
|
type RuntimeLog = Pick<typeof logger, 'info' | 'debug'>;
|
||||||
|
|
||||||
type ExecuteTurnFn = (args: {
|
|
||||||
group: RegisteredGroup;
|
|
||||||
prompt: string;
|
|
||||||
chatJid: string;
|
|
||||||
runId: string;
|
|
||||||
channel: Channel;
|
|
||||||
startSeq: number | null;
|
|
||||||
endSeq: number | null;
|
|
||||||
deliveryRole?: PairedRoomRole;
|
|
||||||
hasHumanMessage?: boolean;
|
|
||||||
forcedRole?: PairedRoomRole;
|
|
||||||
forcedAgentType?: AgentType;
|
|
||||||
}) => Promise<{
|
|
||||||
outputStatus: 'success' | 'error';
|
|
||||||
deliverySucceeded: boolean;
|
|
||||||
visiblePhase: unknown;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function enqueueGenericFollowUpAfterDeliveryRetry(args: {
|
export function enqueueGenericFollowUpAfterDeliveryRetry(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
|||||||
88
src/message-runtime-gating.test.ts
Normal file
88
src/message-runtime-gating.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSessionCommandMock,
|
||||||
|
hasAllowedTriggerMock,
|
||||||
|
loggerInfoMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
handleSessionCommandMock: vi.fn(),
|
||||||
|
hasAllowedTriggerMock: vi.fn(),
|
||||||
|
loggerInfoMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./session-commands.js', () => ({
|
||||||
|
handleSessionCommand: handleSessionCommandMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./message-runtime-rules.js', () => ({
|
||||||
|
hasAllowedTrigger: hasAllowedTriggerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: loggerInfoMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||||
|
|
||||||
|
describe('message-runtime-gating', () => {
|
||||||
|
const baseArgs = {
|
||||||
|
chatJid: 'room-1',
|
||||||
|
group: {
|
||||||
|
folder: 'room-folder',
|
||||||
|
name: 'room',
|
||||||
|
isMain: false,
|
||||||
|
trigger: '코덱스',
|
||||||
|
added_at: new Date().toISOString(),
|
||||||
|
} satisfies RegisteredGroup,
|
||||||
|
runId: 'run-1',
|
||||||
|
missedMessages: [],
|
||||||
|
triggerPattern: /^코덱스/,
|
||||||
|
timezone: 'Asia/Seoul',
|
||||||
|
hasImplicitContinuationWindow: () => false,
|
||||||
|
sessionCommandDeps: {} as never,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
handleSessionCommandMock.mockReset();
|
||||||
|
hasAllowedTriggerMock.mockReset();
|
||||||
|
loggerInfoMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns session-command results directly when a command is handled', async () => {
|
||||||
|
handleSessionCommandMock.mockResolvedValue({
|
||||||
|
handled: true,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleQueuedRunGates(baseArgs);
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: false });
|
||||||
|
expect(hasAllowedTriggerMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing triggers as a handled no-op', async () => {
|
||||||
|
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||||
|
hasAllowedTriggerMock.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await handleQueuedRunGates(baseArgs);
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(loggerInfoMock).toHaveBeenCalledWith(
|
||||||
|
{ chatJid: 'room-1', group: 'room', runId: 'run-1' },
|
||||||
|
'Skipping queued run because no allowed trigger was found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through when no session command is handled and the trigger is allowed', async () => {
|
||||||
|
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||||
|
hasAllowedTriggerMock.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await handleQueuedRunGates(baseArgs);
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: false });
|
||||||
|
expect(loggerInfoMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,30 +14,12 @@ import {
|
|||||||
resolveHandoffCursorKey,
|
resolveHandoffCursorKey,
|
||||||
resolveHandoffRoleOverride,
|
resolveHandoffRoleOverride,
|
||||||
} from './message-runtime-shared.js';
|
} from './message-runtime-shared.js';
|
||||||
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
|
||||||
Channel,
|
Channel,
|
||||||
PairedRoomRole,
|
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
type ExecuteTurnFn = (args: {
|
|
||||||
group: RegisteredGroup;
|
|
||||||
prompt: string;
|
|
||||||
chatJid: string;
|
|
||||||
runId: string;
|
|
||||||
channel: Channel;
|
|
||||||
startSeq: number | null;
|
|
||||||
endSeq: number | null;
|
|
||||||
deliveryRole?: PairedRoomRole;
|
|
||||||
forcedRole?: PairedRoomRole;
|
|
||||||
forcedAgentType?: AgentType;
|
|
||||||
}) => Promise<{
|
|
||||||
outputStatus: 'success' | 'error';
|
|
||||||
deliverySucceeded: boolean;
|
|
||||||
visiblePhase: unknown;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function enqueuePendingHandoffs(args: {
|
export function enqueuePendingHandoffs(args: {
|
||||||
enqueueTask: (
|
enqueueTask: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
|
|||||||
@@ -10,35 +10,16 @@ import {
|
|||||||
advanceLastAgentCursor,
|
advanceLastAgentCursor,
|
||||||
getProcessableMessages,
|
getProcessableMessages,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||||
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
import { findChannel, formatMessages } from './router.js';
|
import { findChannel, formatMessages } from './router.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
PairedRoomRole,
|
|
||||||
PairedTask,
|
PairedTask,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
type ExecuteTurnFn = (args: {
|
|
||||||
group: RegisteredGroup;
|
|
||||||
prompt: string;
|
|
||||||
chatJid: string;
|
|
||||||
runId: string;
|
|
||||||
channel: Channel;
|
|
||||||
startSeq: number | null;
|
|
||||||
endSeq: number | null;
|
|
||||||
deliveryRole?: PairedRoomRole;
|
|
||||||
hasHumanMessage?: boolean;
|
|
||||||
forcedRole?: PairedRoomRole;
|
|
||||||
forcedAgentType?: AgentType;
|
|
||||||
}) => Promise<{
|
|
||||||
outputStatus: 'success' | 'error';
|
|
||||||
deliverySucceeded: boolean;
|
|
||||||
visiblePhase: unknown;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export async function processMessageLoopTick(args: {
|
export async function processMessageLoopTick(args: {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
failureFinalText: string;
|
failureFinalText: string;
|
||||||
|
|||||||
@@ -16,37 +16,16 @@ import {
|
|||||||
resolveQueuedTurnRole,
|
resolveQueuedTurnRole,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
ExecuteTurnFn,
|
||||||
|
RoleToChannelMap,
|
||||||
|
} from './message-runtime-types.js';
|
||||||
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
PairedRoomRole,
|
|
||||||
PairedTask,
|
PairedTask,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
type ExecuteTurnFn = (args: {
|
|
||||||
group: RegisteredGroup;
|
|
||||||
prompt: string;
|
|
||||||
chatJid: string;
|
|
||||||
runId: string;
|
|
||||||
channel: Channel;
|
|
||||||
startSeq: number | null;
|
|
||||||
endSeq: number | null;
|
|
||||||
deliveryRole?: PairedRoomRole;
|
|
||||||
hasHumanMessage?: boolean;
|
|
||||||
forcedRole?: PairedRoomRole;
|
|
||||||
forcedAgentType?: AgentType;
|
|
||||||
}) => Promise<{
|
|
||||||
outputStatus: 'success' | 'error';
|
|
||||||
deliverySucceeded: boolean;
|
|
||||||
visiblePhase: unknown;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type RoleToChannelMap = Record<
|
|
||||||
'owner' | 'reviewer' | 'arbiter',
|
|
||||||
Channel | null
|
|
||||||
>;
|
|
||||||
|
|
||||||
export async function runPendingPairedTurnIfNeeded(args: {
|
export async function runPendingPairedTurnIfNeeded(args: {
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
|
|||||||
56
src/message-runtime-shared.test.ts
Normal file
56
src/message-runtime-shared.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getFixedRoleChannelName,
|
||||||
|
getMissingRoleChannelMessage,
|
||||||
|
resolveHandoffCursorKey,
|
||||||
|
resolveHandoffRoleOverride,
|
||||||
|
} from './message-runtime-shared.js';
|
||||||
|
|
||||||
|
describe('message-runtime-shared', () => {
|
||||||
|
it('prefers explicit target role over inferred handoff metadata', () => {
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: 'arbiter',
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'reviewer-follow-up',
|
||||||
|
}),
|
||||||
|
).toBe('arbiter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back from intended role to reason prefix when needed', () => {
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'arbiter-follow-up',
|
||||||
|
}),
|
||||||
|
).toBe('reviewer');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
target_role: null,
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'arbiter-follow-up',
|
||||||
|
}),
|
||||||
|
).toBe('arbiter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds owner and role-scoped cursor keys', () => {
|
||||||
|
expect(resolveHandoffCursorKey('room-1')).toBe('room-1');
|
||||||
|
expect(resolveHandoffCursorKey('room-1', 'owner')).toBe('room-1');
|
||||||
|
expect(resolveHandoffCursorKey('room-1', 'reviewer')).toBe(
|
||||||
|
'room-1:reviewer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fixed role channel names and user-facing missing-channel errors', () => {
|
||||||
|
expect(getFixedRoleChannelName('reviewer')).toBe('discord-review');
|
||||||
|
expect(getFixedRoleChannelName('arbiter')).toBe('discord-arbiter');
|
||||||
|
expect(getMissingRoleChannelMessage('reviewer')).toContain(
|
||||||
|
'discord-review',
|
||||||
|
);
|
||||||
|
expect(getMissingRoleChannelMessage('arbiter')).toContain(
|
||||||
|
'discord-arbiter',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/message-runtime-types.ts
Normal file
29
src/message-runtime-types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
PairedRoomRole,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export type ExecuteTurnFn = (args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
channel: Channel;
|
||||||
|
startSeq: number | null;
|
||||||
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
}) => Promise<{
|
||||||
|
outputStatus: 'success' | 'error';
|
||||||
|
deliverySucceeded: boolean;
|
||||||
|
visiblePhase: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RoleToChannelMap = Record<
|
||||||
|
'owner' | 'reviewer' | 'arbiter',
|
||||||
|
Channel | null
|
||||||
|
>;
|
||||||
155
src/task-scheduler-github.ts
Normal file
155
src/task-scheduler-github.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { getErrorMessage } from './utils.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteTask,
|
||||||
|
getTaskById,
|
||||||
|
logTaskRun,
|
||||||
|
updateTask,
|
||||||
|
updateTaskAfterRun,
|
||||||
|
} from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { createTaskStatusTracker } from './task-status-tracker.js';
|
||||||
|
import { extractWatchCiTarget } from './task-watch-status.js';
|
||||||
|
import {
|
||||||
|
checkGitHubActionsRun,
|
||||||
|
computeGitHubWatcherDelayMs,
|
||||||
|
MAX_GITHUB_CONSECUTIVE_ERRORS,
|
||||||
|
parseGitHubCiMetadata,
|
||||||
|
serializeGitHubCiMetadata,
|
||||||
|
} from './github-ci.js';
|
||||||
|
import { sendScheduledMessage } from './task-scheduler-runtime.js';
|
||||||
|
import type { SchedulerDependencies } from './task-scheduler-types.js';
|
||||||
|
import type { ScheduledTask } from './types.js';
|
||||||
|
|
||||||
|
export async function runGithubCiTask(
|
||||||
|
task: ScheduledTask,
|
||||||
|
deps: SchedulerDependencies,
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const runAtIso = new Date().toISOString();
|
||||||
|
let result: string | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
let completedAndDeleted = false;
|
||||||
|
let paused = false;
|
||||||
|
const statusTracker = createTaskStatusTracker(task, {
|
||||||
|
sendTrackedMessage: deps.sendTrackedMessage,
|
||||||
|
editTrackedMessage: deps.editTrackedMessage,
|
||||||
|
});
|
||||||
|
const parsedMetadata = parseGitHubCiMetadata(task.ci_metadata);
|
||||||
|
const metadata = parsedMetadata
|
||||||
|
? {
|
||||||
|
...parsedMetadata,
|
||||||
|
poll_count: (parsedMetadata.poll_count ?? 0) + 1,
|
||||||
|
last_checked_at: runAtIso,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await statusTracker.update('checking');
|
||||||
|
|
||||||
|
const check = await checkGitHubActionsRun(task);
|
||||||
|
result = check.resultSummary;
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
metadata.consecutive_errors = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.terminal) {
|
||||||
|
await statusTracker.update('completed');
|
||||||
|
if (check.completionMessage) {
|
||||||
|
await sendScheduledMessage(deps, task.chat_jid, check.completionMessage);
|
||||||
|
}
|
||||||
|
deleteTask(task.id);
|
||||||
|
completedAndDeleted = true;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
groupFolder: task.group_folder,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
},
|
||||||
|
'GitHub CI watcher completed and deleted',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
groupFolder: task.group_folder,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
'GitHub CI watcher checked non-terminal run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = getErrorMessage(err);
|
||||||
|
if (metadata) {
|
||||||
|
metadata.consecutive_errors = (metadata.consecutive_errors ?? 0) + 1;
|
||||||
|
}
|
||||||
|
logger.error({ taskId: task.id, error }, 'GitHub CI watcher failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
const currentTask = getTaskById(task.id);
|
||||||
|
const nextRun = currentTask
|
||||||
|
? new Date(
|
||||||
|
Date.now() + computeGitHubWatcherDelayMs(currentTask, Date.now()),
|
||||||
|
).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!currentTask) {
|
||||||
|
if (!completedAndDeleted) {
|
||||||
|
await statusTracker.update('completed');
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{ taskId: task.id },
|
||||||
|
'GitHub CI watcher deleted during execution, skipping persistence',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
updateTask(task.id, { ci_metadata: serializeGitHubCiMetadata(metadata) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
metadata &&
|
||||||
|
(metadata.consecutive_errors ?? 0) >= MAX_GITHUB_CONSECUTIVE_ERRORS
|
||||||
|
) {
|
||||||
|
paused = true;
|
||||||
|
updateTask(task.id, { status: 'paused' });
|
||||||
|
await deps.sendMessage(
|
||||||
|
task.chat_jid,
|
||||||
|
[
|
||||||
|
`CI 감시 일시정지: ${extractWatchCiTarget(task.prompt) || task.id}`,
|
||||||
|
`- 사유: gh api 연속 ${metadata.consecutive_errors}회 실패`,
|
||||||
|
`- 마지막 오류: ${error.slice(0, 200)}`,
|
||||||
|
`- 태스크 ID: \`${task.id}\``,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !paused) {
|
||||||
|
await statusTracker.update('retrying', nextRun);
|
||||||
|
} else if (paused) {
|
||||||
|
// Paused tasks keep their current status message state; the pause notice is sent separately.
|
||||||
|
} else if (nextRun) {
|
||||||
|
await statusTracker.update('waiting', nextRun);
|
||||||
|
} else {
|
||||||
|
await statusTracker.update('completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
logTaskRun({
|
||||||
|
task_id: task.id,
|
||||||
|
run_at: new Date().toISOString(),
|
||||||
|
duration_ms: durationMs,
|
||||||
|
status: error ? 'error' : 'success',
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTaskAfterRun(
|
||||||
|
task.id,
|
||||||
|
nextRun,
|
||||||
|
error ? `Error: ${error}` : result || 'Completed',
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/task-scheduler-runtime.ts
Normal file
127
src/task-scheduler-runtime.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { writeTasksSnapshot } from './agent-runner.js';
|
||||||
|
import { getAllTasks } from './db.js';
|
||||||
|
import {
|
||||||
|
resolveGroupFolderPath,
|
||||||
|
resolveGroupIpcPath,
|
||||||
|
resolveTaskRuntimeIpcPath,
|
||||||
|
} from './group-folder.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { hasReviewerLease } from './service-routing.js';
|
||||||
|
import {
|
||||||
|
getTaskQueueJid,
|
||||||
|
getTaskRuntimeTaskId,
|
||||||
|
shouldUseTaskScopedSession,
|
||||||
|
} from './task-watch-status.js';
|
||||||
|
import type { AgentType, ScheduledTask } from './types.js';
|
||||||
|
import type {
|
||||||
|
SchedulerDependencies,
|
||||||
|
TaskExecutionContext,
|
||||||
|
} from './task-scheduler-types.js';
|
||||||
|
|
||||||
|
export function hasTaskExceededMaxDuration(
|
||||||
|
task: Pick<ScheduledTask, 'id' | 'created_at' | 'max_duration_ms'>,
|
||||||
|
nowMs: number,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
task.max_duration_ms === null ||
|
||||||
|
task.max_duration_ms === undefined ||
|
||||||
|
!Number.isFinite(task.max_duration_ms) ||
|
||||||
|
task.max_duration_ms <= 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtMs = new Date(task.created_at).getTime();
|
||||||
|
if (!Number.isFinite(createdAtMs)) {
|
||||||
|
logger.warn(
|
||||||
|
{ taskId: task.id, createdAt: task.created_at },
|
||||||
|
'Task has invalid created_at for max duration enforcement',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nowMs - createdAtMs >= task.max_duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendScheduledMessage(
|
||||||
|
deps: SchedulerDependencies,
|
||||||
|
chatJid: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!hasReviewerLease(chatJid)) {
|
||||||
|
await deps.sendMessage(chatJid, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.sendMessageViaReviewerBot) {
|
||||||
|
throw new Error(
|
||||||
|
'Paired-room scheduled output requires a configured reviewer Discord bot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.sendMessageViaReviewerBot(chatJid, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTaskExecutionContext(
|
||||||
|
task: ScheduledTask,
|
||||||
|
deps: SchedulerDependencies,
|
||||||
|
): TaskExecutionContext {
|
||||||
|
const groupDir = resolveGroupFolderPath(task.group_folder);
|
||||||
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
|
const groups = deps.registeredGroups();
|
||||||
|
const group = Object.values(groups).find(
|
||||||
|
(registeredGroup) => registeredGroup.folder === task.group_folder,
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Group not found: ${task.group_folder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = group.isMain === true;
|
||||||
|
const taskAgentType =
|
||||||
|
task.agent_type || deps.serviceAgentType || 'claude-code';
|
||||||
|
const sessions = deps.getSessions();
|
||||||
|
const runtimeTaskId = getTaskRuntimeTaskId(task);
|
||||||
|
const useTaskScopedSession = shouldUseTaskScopedSession(task);
|
||||||
|
const runtimeIpcDir = runtimeTaskId
|
||||||
|
? resolveTaskRuntimeIpcPath(task.group_folder, runtimeTaskId)
|
||||||
|
: resolveGroupIpcPath(task.group_folder);
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
groupDir,
|
||||||
|
isMain,
|
||||||
|
queueJid: getTaskQueueJid(task),
|
||||||
|
runtimeIpcDir,
|
||||||
|
runtimeTaskId,
|
||||||
|
sessionId:
|
||||||
|
task.context_mode === 'group' ? sessions[task.group_folder] : undefined,
|
||||||
|
useTaskScopedSession,
|
||||||
|
taskAgentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTaskSnapshotForGroup(
|
||||||
|
taskAgentType: AgentType,
|
||||||
|
groupFolder: string,
|
||||||
|
isMain: boolean,
|
||||||
|
runtimeTaskId?: string,
|
||||||
|
): void {
|
||||||
|
const tasks = getAllTasks(taskAgentType);
|
||||||
|
writeTasksSnapshot(
|
||||||
|
groupFolder,
|
||||||
|
isMain,
|
||||||
|
tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
groupFolder: task.group_folder,
|
||||||
|
prompt: task.prompt,
|
||||||
|
schedule_type: task.schedule_type,
|
||||||
|
schedule_value: task.schedule_value,
|
||||||
|
status: task.status,
|
||||||
|
next_run: task.next_run,
|
||||||
|
})),
|
||||||
|
runtimeTaskId,
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/task-scheduler-types.ts
Normal file
37
src/task-scheduler-types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
|
|
||||||
|
import type { GroupQueue } from './group-queue.js';
|
||||||
|
import type { AgentType, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
export interface SchedulerDependencies {
|
||||||
|
serviceAgentType?: AgentType;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
getSessions: () => Record<string, string>;
|
||||||
|
queue: GroupQueue;
|
||||||
|
onProcess: (
|
||||||
|
groupJid: string,
|
||||||
|
proc: ChildProcess,
|
||||||
|
processName: string,
|
||||||
|
ipcDir: string,
|
||||||
|
) => void;
|
||||||
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
|
sendMessageViaReviewerBot?: (jid: string, text: string) => Promise<void>;
|
||||||
|
sendTrackedMessage?: (jid: string, text: string) => Promise<string | null>;
|
||||||
|
editTrackedMessage?: (
|
||||||
|
jid: string,
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskExecutionContext {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
groupDir: string;
|
||||||
|
isMain: boolean;
|
||||||
|
queueJid: string;
|
||||||
|
runtimeIpcDir: string;
|
||||||
|
runtimeTaskId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
useTaskScopedSession: boolean;
|
||||||
|
taskAgentType: AgentType;
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ChildProcess } from 'child_process';
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
|
||||||
import { getAgentOutputText } from './agent-output.js';
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import { createEvaluatedOutputHandler } from './agent-attempt.js';
|
import { createEvaluatedOutputHandler } from './agent-attempt.js';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +12,6 @@ import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
|
|||||||
import {
|
import {
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
writeTasksSnapshot,
|
|
||||||
} from './agent-runner.js';
|
} from './agent-runner.js';
|
||||||
import {
|
import {
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
@@ -25,12 +22,6 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
updateTaskAfterRun,
|
updateTaskAfterRun,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
|
||||||
import {
|
|
||||||
resolveGroupFolderPath,
|
|
||||||
resolveGroupIpcPath,
|
|
||||||
resolveTaskRuntimeIpcPath,
|
|
||||||
} from './group-folder.js';
|
|
||||||
import { createScopedLogger, logger } from './logger.js';
|
import { createScopedLogger, logger } from './logger.js';
|
||||||
import { createTaskStatusTracker } from './task-status-tracker.js';
|
import { createTaskStatusTracker } from './task-status-tracker.js';
|
||||||
import {
|
import {
|
||||||
@@ -57,19 +48,20 @@ import {
|
|||||||
import {
|
import {
|
||||||
extractWatchCiTarget,
|
extractWatchCiTarget,
|
||||||
getTaskQueueJid,
|
getTaskQueueJid,
|
||||||
getTaskRuntimeTaskId,
|
|
||||||
isGitHubCiTask,
|
isGitHubCiTask,
|
||||||
shouldUseTaskScopedSession,
|
|
||||||
} from './task-watch-status.js';
|
} from './task-watch-status.js';
|
||||||
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
import { ScheduledTask } from './types.js';
|
||||||
import { hasReviewerLease } from './service-routing.js';
|
|
||||||
import {
|
import {
|
||||||
checkGitHubActionsRun,
|
hasTaskExceededMaxDuration,
|
||||||
computeGitHubWatcherDelayMs,
|
resolveTaskExecutionContext,
|
||||||
MAX_GITHUB_CONSECUTIVE_ERRORS,
|
sendScheduledMessage,
|
||||||
parseGitHubCiMetadata,
|
writeTaskSnapshotForGroup,
|
||||||
serializeGitHubCiMetadata,
|
} from './task-scheduler-runtime.js';
|
||||||
} from './github-ci.js';
|
import { runGithubCiTask } from './task-scheduler-github.js';
|
||||||
|
import type {
|
||||||
|
SchedulerDependencies,
|
||||||
|
TaskExecutionContext,
|
||||||
|
} from './task-scheduler-types.js';
|
||||||
export {
|
export {
|
||||||
extractWatchCiTarget,
|
extractWatchCiTarget,
|
||||||
getTaskQueueJid,
|
getTaskQueueJid,
|
||||||
@@ -79,6 +71,7 @@ export {
|
|||||||
renderWatchCiStatusMessage,
|
renderWatchCiStatusMessage,
|
||||||
shouldUseTaskScopedSession,
|
shouldUseTaskScopedSession,
|
||||||
} from './task-watch-status.js';
|
} from './task-watch-status.js';
|
||||||
|
export type { SchedulerDependencies } from './task-scheduler-types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the next run time for a recurring task, anchored to the
|
* Compute the next run time for a recurring task, anchored to the
|
||||||
@@ -121,146 +114,6 @@ export function computeNextRun(task: ScheduledTask): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasTaskExceededMaxDuration(
|
|
||||||
task: Pick<ScheduledTask, 'id' | 'created_at' | 'max_duration_ms'>,
|
|
||||||
nowMs: number,
|
|
||||||
): boolean {
|
|
||||||
if (
|
|
||||||
task.max_duration_ms === null ||
|
|
||||||
task.max_duration_ms === undefined ||
|
|
||||||
!Number.isFinite(task.max_duration_ms) ||
|
|
||||||
task.max_duration_ms <= 0
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAtMs = new Date(task.created_at).getTime();
|
|
||||||
if (!Number.isFinite(createdAtMs)) {
|
|
||||||
logger.warn(
|
|
||||||
{ taskId: task.id, createdAt: task.created_at },
|
|
||||||
'Task has invalid created_at for max duration enforcement',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nowMs - createdAtMs >= task.max_duration_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchedulerDependencies {
|
|
||||||
serviceAgentType?: AgentType;
|
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
|
||||||
getSessions: () => Record<string, string>;
|
|
||||||
queue: GroupQueue;
|
|
||||||
onProcess: (
|
|
||||||
groupJid: string,
|
|
||||||
proc: ChildProcess,
|
|
||||||
processName: string,
|
|
||||||
ipcDir: string,
|
|
||||||
) => void;
|
|
||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
|
||||||
/** Send a message via the reviewer bot identity so the owner treats it as a peer request. */
|
|
||||||
sendMessageViaReviewerBot?: (jid: string, text: string) => Promise<void>;
|
|
||||||
sendTrackedMessage?: (jid: string, text: string) => Promise<string | null>;
|
|
||||||
editTrackedMessage?: (
|
|
||||||
jid: string,
|
|
||||||
messageId: string,
|
|
||||||
text: string,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskExecutionContext {
|
|
||||||
group: RegisteredGroup;
|
|
||||||
groupDir: string;
|
|
||||||
isMain: boolean;
|
|
||||||
queueJid: string;
|
|
||||||
runtimeIpcDir: string;
|
|
||||||
runtimeTaskId?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
useTaskScopedSession: boolean;
|
|
||||||
taskAgentType: AgentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendScheduledMessage(
|
|
||||||
deps: SchedulerDependencies,
|
|
||||||
chatJid: string,
|
|
||||||
text: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!hasReviewerLease(chatJid)) {
|
|
||||||
await deps.sendMessage(chatJid, text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deps.sendMessageViaReviewerBot) {
|
|
||||||
throw new Error(
|
|
||||||
'Paired-room scheduled output requires a configured reviewer Discord bot',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await deps.sendMessageViaReviewerBot(chatJid, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTaskExecutionContext(
|
|
||||||
task: ScheduledTask,
|
|
||||||
deps: SchedulerDependencies,
|
|
||||||
): TaskExecutionContext {
|
|
||||||
const groupDir = resolveGroupFolderPath(task.group_folder);
|
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
|
||||||
|
|
||||||
const groups = deps.registeredGroups();
|
|
||||||
const group = Object.values(groups).find(
|
|
||||||
(registeredGroup) => registeredGroup.folder === task.group_folder,
|
|
||||||
);
|
|
||||||
if (!group) {
|
|
||||||
throw new Error(`Group not found: ${task.group_folder}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMain = group.isMain === true;
|
|
||||||
const taskAgentType =
|
|
||||||
task.agent_type || deps.serviceAgentType || 'claude-code';
|
|
||||||
const sessions = deps.getSessions();
|
|
||||||
const runtimeTaskId = getTaskRuntimeTaskId(task);
|
|
||||||
const useTaskScopedSession = shouldUseTaskScopedSession(task);
|
|
||||||
const runtimeIpcDir = runtimeTaskId
|
|
||||||
? resolveTaskRuntimeIpcPath(task.group_folder, runtimeTaskId)
|
|
||||||
: resolveGroupIpcPath(task.group_folder);
|
|
||||||
|
|
||||||
return {
|
|
||||||
group,
|
|
||||||
groupDir,
|
|
||||||
isMain,
|
|
||||||
queueJid: getTaskQueueJid(task),
|
|
||||||
runtimeIpcDir,
|
|
||||||
runtimeTaskId,
|
|
||||||
sessionId:
|
|
||||||
task.context_mode === 'group' ? sessions[task.group_folder] : undefined,
|
|
||||||
useTaskScopedSession,
|
|
||||||
taskAgentType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeTaskSnapshotForGroup(
|
|
||||||
taskAgentType: AgentType,
|
|
||||||
groupFolder: string,
|
|
||||||
isMain: boolean,
|
|
||||||
runtimeTaskId?: string,
|
|
||||||
): void {
|
|
||||||
const tasks = getAllTasks(taskAgentType);
|
|
||||||
writeTasksSnapshot(
|
|
||||||
groupFolder,
|
|
||||||
isMain,
|
|
||||||
tasks.map((task) => ({
|
|
||||||
id: task.id,
|
|
||||||
groupFolder: task.group_folder,
|
|
||||||
prompt: task.prompt,
|
|
||||||
schedule_type: task.schedule_type,
|
|
||||||
schedule_value: task.schedule_value,
|
|
||||||
status: task.status,
|
|
||||||
next_run: task.next_run,
|
|
||||||
})),
|
|
||||||
runtimeTaskId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTask(
|
async function runTask(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
deps: SchedulerDependencies,
|
deps: SchedulerDependencies,
|
||||||
@@ -629,143 +482,6 @@ async function runTask(
|
|||||||
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runGithubCiTask(
|
|
||||||
task: ScheduledTask,
|
|
||||||
deps: SchedulerDependencies,
|
|
||||||
): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const runAtIso = new Date().toISOString();
|
|
||||||
let result: string | null = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
let completedAndDeleted = false;
|
|
||||||
let paused = false;
|
|
||||||
const statusTracker = createTaskStatusTracker(task, {
|
|
||||||
sendTrackedMessage: deps.sendTrackedMessage,
|
|
||||||
editTrackedMessage: deps.editTrackedMessage,
|
|
||||||
});
|
|
||||||
const parsedMetadata = parseGitHubCiMetadata(task.ci_metadata);
|
|
||||||
const metadata = parsedMetadata
|
|
||||||
? {
|
|
||||||
...parsedMetadata,
|
|
||||||
poll_count: (parsedMetadata.poll_count ?? 0) + 1,
|
|
||||||
last_checked_at: runAtIso,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await statusTracker.update('checking');
|
|
||||||
|
|
||||||
const check = await checkGitHubActionsRun(task);
|
|
||||||
result = check.resultSummary;
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
metadata.consecutive_errors = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (check.terminal) {
|
|
||||||
await statusTracker.update('completed');
|
|
||||||
if (check.completionMessage) {
|
|
||||||
await sendScheduledMessage(
|
|
||||||
deps,
|
|
||||||
task.chat_jid,
|
|
||||||
check.completionMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
deleteTask(task.id);
|
|
||||||
completedAndDeleted = true;
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
taskId: task.id,
|
|
||||||
groupFolder: task.group_folder,
|
|
||||||
durationMs: Date.now() - startTime,
|
|
||||||
},
|
|
||||||
'GitHub CI watcher completed and deleted',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
taskId: task.id,
|
|
||||||
groupFolder: task.group_folder,
|
|
||||||
result,
|
|
||||||
},
|
|
||||||
'GitHub CI watcher checked non-terminal run',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = getErrorMessage(err);
|
|
||||||
if (metadata) {
|
|
||||||
metadata.consecutive_errors = (metadata.consecutive_errors ?? 0) + 1;
|
|
||||||
}
|
|
||||||
logger.error({ taskId: task.id, error }, 'GitHub CI watcher failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
|
||||||
const currentTask = getTaskById(task.id);
|
|
||||||
const nextRun = currentTask
|
|
||||||
? new Date(
|
|
||||||
Date.now() + computeGitHubWatcherDelayMs(currentTask, Date.now()),
|
|
||||||
).toISOString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!currentTask) {
|
|
||||||
if (!completedAndDeleted) {
|
|
||||||
await statusTracker.update('completed');
|
|
||||||
}
|
|
||||||
logger.debug(
|
|
||||||
{ taskId: task.id },
|
|
||||||
'GitHub CI watcher deleted during execution, skipping persistence',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata) {
|
|
||||||
updateTask(task.id, { ci_metadata: serializeGitHubCiMetadata(metadata) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
error &&
|
|
||||||
metadata &&
|
|
||||||
(metadata.consecutive_errors ?? 0) >= MAX_GITHUB_CONSECUTIVE_ERRORS
|
|
||||||
) {
|
|
||||||
paused = true;
|
|
||||||
updateTask(task.id, { status: 'paused' });
|
|
||||||
await deps.sendMessage(
|
|
||||||
task.chat_jid,
|
|
||||||
[
|
|
||||||
`CI 감시 일시정지: ${extractWatchCiTarget(task.prompt) || task.id}`,
|
|
||||||
`- 사유: gh api 연속 ${metadata.consecutive_errors}회 실패`,
|
|
||||||
`- 마지막 오류: ${error.slice(0, 200)}`,
|
|
||||||
`- 태스크 ID: \`${task.id}\``,
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !paused) {
|
|
||||||
await statusTracker.update('retrying', nextRun);
|
|
||||||
} else if (paused) {
|
|
||||||
// Paused tasks keep their current status message state; the pause notice is sent separately.
|
|
||||||
} else if (nextRun) {
|
|
||||||
await statusTracker.update('waiting', nextRun);
|
|
||||||
} else {
|
|
||||||
await statusTracker.update('completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
logTaskRun({
|
|
||||||
task_id: task.id,
|
|
||||||
run_at: new Date().toISOString(),
|
|
||||||
duration_ms: durationMs,
|
|
||||||
status: error ? 'error' : 'success',
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateTaskAfterRun(
|
|
||||||
task.id,
|
|
||||||
nextRun,
|
|
||||||
error ? `Error: ${error}` : result || 'Completed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute one scheduler tick without timer/in-flight coordination.
|
* Execute one scheduler tick without timer/in-flight coordination.
|
||||||
* This keeps the scheduler loop focused on timing while tests and debugging
|
* This keeps the scheduler loop focused on timing while tests and debugging
|
||||||
|
|||||||
Reference in New Issue
Block a user