diff --git a/.changeset/fix-bugbot-detail-dispatch.md b/.changeset/fix-bugbot-detail-dispatch.md index 08faed37b..1298f0b5d 100644 --- a/.changeset/fix-bugbot-detail-dispatch.md +++ b/.changeset/fix-bugbot-detail-dispatch.md @@ -1,9 +1,12 @@ --- -"@aoagents/ao-core": patch +"@aoagents/ao-core": minor --- -Fix review-check logic missing new bugbot comments from the latest push (#895). The `bugbot-comments` reaction now dispatches a detailed message listing every already-fetched automated comment (severity, path:line, excerpt, URL) plus explicit correct-API guidance (`/reviews` → paginated `/reviews/{id}/comments` → paginated `/pulls/{pr}/comments` with `in_reply_to_id`), so the agent never has to rediscover comments with a first-page-only scan. +Enrich lifecycle events with PR/issue context for webhook consumers. All events now carry `data.context` with `pr` (url, title, number, branch), `issueId`, `issueTitle`, `summary`, and `branch` when available, plus `data.schemaVersion: 2`. -**Safe by design:** -- Only replaces the message when it matches the built-in sentinel `DEFAULT_BUGBOT_COMMENTS_MESSAGE` — projects that customized `reactions.bugbot-comments.message` in their YAML are untouched. -- Bot comment bodies are sanitized (backticks stripped) and wrapped in a code span, with an "untrusted data" preamble instructing the agent not to treat excerpts as instructions. +Additional changes: + +- Persist `issueTitle` in session metadata during spawn so it survives across restarts and is available for event enrichment. +- Refactor `executeReaction()` to accept a `Session` object instead of separate `sessionId`/`projectId` arguments. +- Add `maybeDispatchCIFailureDetails()` — when a session enters `ci_failed`, the agent receives a follow-up message with the failed check names and URLs (deduped via fingerprint so subsequent polls don't re-send the same failure set). +- `bugbot-comments` reaction dispatches an enriched message listing every automated comment inline, so the agent doesn't need to re-fetch via `gh api`. diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index 2104a4829..e24cd8bb0 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -42,32 +42,22 @@ interface LockMetadata { acquiredAt: string; } -type ProcessProbeResult = "alive" | "forbidden" | "missing"; - function ensureDir(): void { mkdirSync(STATE_DIR, { recursive: true }); } -function probeProcess(pid: number): ProcessProbeResult { +function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); - return "alive"; + return true; } catch (error: unknown) { if ((error as { code?: string }).code === "EPERM") { - return "forbidden"; + return true; } - return "missing"; + return false; } } -function isLockOwnerAlive(pid: number): boolean { - return probeProcess(pid) !== "missing"; -} - -function isRunningProcessAlive(pid: number): boolean { - return probeProcess(pid) !== "missing"; -} - function readLockMetadata(lockFile: string): LockMetadata | null { try { const raw = readFileSync(lockFile, "utf-8"); @@ -137,7 +127,7 @@ async function acquireLock( const owner = readLockMetadata(lockFile); if ((!owner && isStaleUnparseableLock(lockFile)) - || (owner && !isLockOwnerAlive(owner.pid))) { + || (owner && !isProcessAlive(owner.pid))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; @@ -253,7 +243,7 @@ export async function getRunning(): Promise { const state = readState(); if (!state) return null; - if (!isRunningProcessAlive(state.pid)) { + if (!isProcessAlive(state.pid)) { // Stale entry — process is dead, clean up writeState(null); return null; @@ -282,16 +272,16 @@ export async function acquireStartupLock(timeoutMs = 30000): Promise<() => void> } /** - * Wait for a process to exit, polling isRunningProcessAlive. + * Wait for a process to exit, polling isProcessAlive. * Returns true if the process exited, false if timeout reached. */ export async function waitForExit(pid: number, timeoutMs = 5000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - if (!isRunningProcessAlive(pid)) return true; + if (!isProcessAlive(pid)) return true; await sleep(100); } - return !isRunningProcessAlive(pid); + return !isProcessAlive(pid); } /** diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index a47582f23..e60c8338d 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -2483,6 +2483,7 @@ describe("reactions", () => { const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("closed"), getMergeability, + enrichSessionsPRBatch: mockBatchEnrichment({ state: "closed" }), }); const registry = createMockRegistry({ runtime: plugins.runtime, @@ -3872,3 +3873,143 @@ describe("auto-cleanup on merge (#1309)", () => { expect(meta?.["mergedPendingCleanupSince"]).toMatch(/\d{4}-\d{2}-\d{2}T/); }); }); + +describe("event enrichment", () => { + it("includes PR context in event data when session has PR", async () => { + const notifier = createMockNotifier(); + const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("closed") }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier, + }); + + const session = makeSession({ + status: "pr_open", + pr: makePR({ number: 42, url: "https://github.com/org/repo/pull/42" }), + branch: "feat/test-123", + }); + const lm = setupCheck("app-1", { + session, + registry, + configOverride: { + ...config, + notificationRouting: { + ...config.notificationRouting, + info: ["desktop"], + }, + }, + }); + + await lm.check("app-1"); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: "pr.closed", + data: expect.objectContaining({ + context: expect.objectContaining({ + pr: expect.objectContaining({ + url: "https://github.com/org/repo/pull/42", + number: 42, + }), + branch: "feat/test-123", + }), + schemaVersion: 2, + }), + }), + ); + }); + + it("includes issue context in event data when session has issue", async () => { + const notifier = createMockNotifier(); + const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("closed") }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier, + }); + + const session = makeSession({ + status: "pr_open", + pr: makePR(), + issueId: "INT-123", + metadata: { issueTitle: "Fix login bug" }, + }); + const lm = setupCheck("app-1", { + session, + registry, + configOverride: { + ...config, + notificationRouting: { + ...config.notificationRouting, + info: ["desktop"], + }, + }, + }); + + await lm.check("app-1"); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: "pr.closed", + data: expect.objectContaining({ + context: expect.objectContaining({ + issueId: "INT-123", + issueTitle: "Fix login bug", + }), + schemaVersion: 2, + }), + }), + ); + }); + + it("gracefully omits PR context when session has no PR", async () => { + const notifier = createMockNotifier(); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + notifier, + }); + + // Create a session without PR that will transition to needs_input + const session = makeSession({ + status: "working", + pr: null, + issueId: "INT-456", + }); + // Mock activity detection to return waiting_input + vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ + state: "waiting_input", + timestamp: new Date(), + }); + + const lm = setupCheck("app-1", { + session, + registry, + configOverride: { + ...config, + notificationRouting: { + ...config.notificationRouting, + urgent: ["desktop"], + }, + }, + }); + + await lm.check("app-1"); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: "session.needs_input", + data: expect.objectContaining({ + context: expect.objectContaining({ + pr: null, + issueId: "INT-456", + }), + schemaVersion: 2, + }), + }), + ); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 680466ed5..c536b130b 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -854,6 +854,47 @@ describe("spawn", () => { expect(session.issueId).toBe("INT-100"); }); + it("persists issueTitle to metadata during spawn", async () => { + const mockTracker: Tracker = { + name: "mock-tracker", + getIssue: vi.fn().mockResolvedValue({ + id: "INT-200", + title: "Add dark mode support", + description: "Implement dark mode toggle", + url: "https://linear.app/test/issue/INT-200", + state: "open", + labels: [], + }), + isCompleted: vi.fn().mockResolvedValue(false), + issueUrl: vi.fn().mockReturnValue("https://linear.app/test/issue/INT-200"), + branchName: vi.fn().mockReturnValue("feat/INT-200"), + generatePrompt: vi.fn().mockResolvedValue("Work on INT-200"), + }; + + const registryWithTracker: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + if (slot === "workspace") return mockWorkspace; + if (slot === "tracker") return mockTracker; + return null; + }), + }; + + const sm = createSessionManager({ + config, + registry: registryWithTracker, + }); + + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-200" }); + + // Verify issueTitle is persisted to metadata file and can be read back + const metadata = readMetadata(sessionsDir, session.id); + expect(metadata).not.toBeNull(); + expect(metadata!.issueTitle).toBe("Add dark mode support"); + }); + it("succeeds with ad-hoc issue string when tracker returns IssueNotFoundError", async () => { const mockTracker: Tracker = { name: "mock-tracker", diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index 70e6fb5b6..71fdf440f 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -204,13 +204,20 @@ export function createMockSCM(overrides: Partial = {}): SCM { enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => { const result = new Map(); for (const pr of prs) { + const [state, ciStatus, reviewDecision, mergeability, ciChecks] = await Promise.all([ + scm.getPRState(pr), + scm.getCISummary(pr), + scm.getReviewDecision(pr), + scm.getMergeability(pr), + scm.getCIChecks(pr), + ]); result.set(`${pr.owner}/${pr.repo}#${pr.number}`, { - state: "open" as const, - ciStatus: "passing" as const, - reviewDecision: "none" as const, - mergeable: false, - hasConflicts: false, - ciChecks: [], + state: state ?? "open", + ciStatus: ciStatus ?? "passing", + reviewDecision: reviewDecision ?? "none", + mergeable: mergeability?.mergeable ?? false, + hasConflicts: mergeability ? !mergeability.noConflicts : false, + ciChecks: ciChecks ?? [], }); } return result; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 344eb5052..61eba7f32 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -273,6 +273,71 @@ function prStateToEventType( } } +/** PR context for event enrichment. */ +interface EventPRContext { + url: string; + /** Actual PR title from enrichment cache. null until cache is populated. */ + title: string | null; + number: number; + branch: string; +} + +/** Event context with PR and issue information for webhook payloads. */ +interface EventContext { + pr: EventPRContext | null; + issueId: string | null; + issueTitle: string | null; + /** Agent task summary (NOT the PR title). May describe the work before a PR exists. */ + summary: string | null; + branch: string | null; +} + +/** + * Minimal session context required for reaction execution and event enrichment. + * Used for system-level events (like all-complete) that don't have a real session. + */ +interface ReactionSessionContext { + id: SessionId; + projectId: string; + pr: Session["pr"]; + issueId: string | null; + branch: string | null; + metadata: Record; + agentInfo: Session["agentInfo"]; +} + +/** + * Build event context with PR and issue information for webhook payloads. + * This enriches events with useful metadata so external consumers (Telegram, Discord, etc.) + * can display meaningful information without making additional API calls. + */ +function buildEventContext( + session: Session | ReactionSessionContext, + prEnrichmentCache: Map, +): EventContext { + let pr: EventPRContext | null = null; + + if (session.pr) { + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cached = prEnrichmentCache.get(prKey); + + pr = { + url: session.pr.url, + title: cached?.title ?? null, + number: session.pr.number, + branch: session.pr.branch, + }; + } + + return { + pr, + issueId: session.issueId, + issueTitle: session.metadata["issueTitle"] ?? null, + summary: session.agentInfo?.summary ?? null, + branch: session.branch, + }; +} + /** Map event type to reaction config key. */ function eventToReactionKey(eventType: EventType): string | null { switch (eventType) { @@ -1172,11 +1237,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan /** Execute a reaction for a session. */ async function executeReaction( - sessionId: SessionId, - projectId: string, + session: Session | ReactionSessionContext, reactionKey: string, reactionConfig: ReactionConfig, ): Promise { + const { id: sessionId, projectId } = session; const trackerKey = `${sessionId}:${reactionKey}`; let tracker = reactionTrackers.get(trackerKey); @@ -1215,11 +1280,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (shouldEscalate) { // Escalate to human + const context = buildEventContext(session, prEnrichmentCache); const event = createEvent("reaction.escalated", { sessionId, projectId, message: `Reaction '${reactionKey}' escalated after ${tracker.attempts} attempts`, - data: { reactionKey, attempts: tracker.attempts }, + data: { reactionKey, attempts: tracker.attempts, context, schemaVersion: 2 }, }); await notifyHuman(event, reactionConfig.priority ?? "urgent"); @@ -1265,11 +1331,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } case "notify": { + const context = buildEventContext(session, prEnrichmentCache); const event = createEvent("reaction.triggered", { sessionId, projectId, message: `Reaction '${reactionKey}' triggered notification`, - data: { reactionKey }, + data: { reactionKey, context, schemaVersion: 2 }, }); await notifyHuman(event, reactionConfig.priority ?? "info"); return { @@ -1283,11 +1350,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan case "auto-merge": { // Auto-merge is handled by the SCM plugin // For now, just notify + const context = buildEventContext(session, prEnrichmentCache); const event = createEvent("reaction.triggered", { sessionId, projectId, message: `Reaction '${reactionKey}' triggered auto-merge`, - data: { reactionKey }, + data: { reactionKey, context, schemaVersion: 2 }, }); await notifyHuman(event, "action"); return { @@ -1498,12 +1566,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ...reactionConfig, message: formatReviewCommentsMessage(pendingComments, "reviewer", reviewSummaries), }; - const result = await executeReaction( - session.id, - session.projectId, - humanReactionKey, - enrichedConfig, - ); + const result = await executeReaction(session, humanReactionKey, enrichedConfig); if (result.success) { updateSessionMetadata(session, { lastPendingReviewDispatchHash: pendingFingerprint, @@ -1541,16 +1604,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reactionConfig.action && (reactionConfig.auto !== false || reactionConfig.action === "notify") ) { + // Always enrich with formatted comment listing so the agent has inline + // data and doesn't need to re-fetch via gh api. const enrichedConfig = { ...reactionConfig, message: formatReviewCommentsMessage(automatedComments, "bot"), }; - const result = await executeReaction( - session.id, - session.projectId, - automatedReactionKey, - enrichedConfig, - ); + const result = await executeReaction(session, automatedReactionKey, enrichedConfig); if (result.success) { updateSessionMetadata(session, { lastAutomatedReviewDispatchHash: automatedFingerprint, @@ -1617,6 +1677,138 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } + async function maybeDispatchCIFailureDetails( + session: Session, + _oldStatus: SessionStatus, + newStatus: SessionStatus, + transitionReaction?: { key: string; result: ReactionResult | null }, + ): Promise { + const project = config.projects[session.projectId]; + if (!project || !session.pr) return; + + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (!scm) return; + + const ciReactionKey = "ci-failed"; + + // Clear tracking when PR is closed/merged + if (newStatus === "merged" || newStatus === "killed") { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + return; + } + + // Only dispatch CI details when in ci_failed state + if (newStatus !== "ci_failed") { + // CI is no longer failing — clear tracking so next failure is dispatched fresh + const lastFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + if (lastFingerprint) { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + } + return; + } + + // Fetch individual CI checks for failure details. + // Use batch enrichment data when available to avoid an extra REST call; + // fall back to getCIChecks() when the batch didn't run this cycle. + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cachedEnrichment = prEnrichmentCache.get(prKey); + + let checks: CICheck[]; + if (cachedEnrichment?.ciChecks !== undefined) { + checks = cachedEnrichment.ciChecks; + } else { + try { + checks = await scm.getCIChecks(session.pr); + } catch { + // Failed to fetch checks — skip this cycle + return; + } + } + + const failedChecks = checks.filter( + (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", + ); + if (failedChecks.length === 0) return; + + const ciFingerprint = makeFingerprint( + failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`), + ); + const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? ""; + + // Reset reaction tracker when failure set changes + if (ciFingerprint !== lastCIFingerprint && transitionReaction?.key !== ciReactionKey) { + clearReactionTracker(session.id, ciReactionKey); + } + if (ciFingerprint !== lastCIFingerprint) { + updateSessionMetadata(session, { + lastCIFailureFingerprint: ciFingerprint, + }); + } + + // If the transition reaction already sent a ci-failed reaction (now enriched + // with detailed check info from the batch cache), record the dispatch hash so + // subsequent polls don't re-send the same failure details. + if ( + transitionReaction?.key === ciReactionKey && + transitionReaction.result?.success + ) { + updateSessionMetadata(session, { + lastCIFailureDispatchHash: ciFingerprint, + lastCIFailureDispatchAt: new Date().toISOString(), + }); + return; + } + + // Skip if we already dispatched this exact failure set + if (ciFingerprint === lastCIDispatchHash) return; + + // Dispatch CI failure details directly via sessionManager.send() rather than + // executeReaction() to avoid consuming the ci-failed reaction's retry budget. + // The transition reaction owns escalation; this is a follow-up info delivery. + const reactionConfig = getReactionConfigForSession(session, ciReactionKey); + if ( + reactionConfig && + reactionConfig.action && + (reactionConfig.auto !== false || reactionConfig.action === "notify") + ) { + const detailedMessage = formatCIFailureMessage(failedChecks); + + try { + if (reactionConfig.action === "send-to-agent") { + await sessionManager.send(session.id, detailedMessage); + } else { + // For "notify" action, send to human notifiers instead + const context = buildEventContext(session, prEnrichmentCache); + const event = createEvent("ci.failing", { + sessionId: session.id, + projectId: session.projectId, + message: detailedMessage, + data: { failedChecks: failedChecks.map((c) => c.name), context, schemaVersion: 2 }, + }); + await notifyHuman(event, reactionConfig.priority ?? "warning"); + } + + updateSessionMetadata(session, { + lastCIFailureDispatchHash: ciFingerprint, + lastCIFailureDispatchAt: new Date().toISOString(), + }); + } catch { + // Send failed — will retry on next poll cycle + } + } + } + /** * Dispatch merge conflict notifications to the agent session. * Conflicts are detected from the PR enrichment cache or getMergeability() @@ -1695,12 +1887,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan enrichedConfig.message = `Your PR branch${behindNote} has merge conflicts with ${baseBranch}. Rebase your branch on ${baseBranch}, resolve the conflicts, and push. You should not need to call gh for merge status unless you need additional context — this information is current.`; } - const result = await executeReaction( - session.id, - session.projectId, - conflictReactionKey, - enrichedConfig, - ); + const result = await executeReaction(session, conflictReactionKey, enrichedConfig); // Only set dedup flag for non-escalated success — escalation hands off // to the human, so we must NOT suppress future agent dispatches if the // condition recurs after the tracker resets. @@ -1975,12 +2162,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reactionConfig && reactionConfig.action) { // auto: false skips automated agent actions but still allows notifications if (reactionConfig.auto !== false || reactionConfig.action === "notify") { - const reactionResult = await executeReaction( - session.id, - session.projectId, - reactionKey, - reactionConfig, - ); + const reactionResult = await executeReaction(session, reactionKey, reactionConfig); transitionReaction = { key: reactionKey, result: reactionResult }; observer.recordOperation({ metric: "lifecycle_poll", @@ -2016,11 +2198,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // so the config controls which notifiers receive each priority level. if (!reactionHandledNotify) { const priority = inferPriority(eventType); + const context = buildEventContext(session, prEnrichmentCache); const event = createEvent(eventType, { sessionId: session.id, projectId: session.projectId, message: `${session.id}: ${oldStatus} → ${newStatus}`, - data: { oldStatus, newStatus }, + data: { oldStatus, newStatus, context, schemaVersion: 2 }, }); await notifyHuman(event, priority); } @@ -2061,13 +2244,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const reactionConfig = getReactionConfigForSession(session, reactionKey); if (reactionConfig && reactionConfig.action) { if (reactionConfig.auto !== false || reactionConfig.action === "notify") { - await executeReaction(session.id, session.projectId, reactionKey, reactionConfig); + await executeReaction(session, reactionKey, reactionConfig); reactionHandledNotify = true; } } } if (!reactionHandledNotify) { + const context = buildEventContext(session, prEnrichmentCache); const prEvent = createEvent(prEventType, { sessionId: session.id, projectId: session.projectId, @@ -2075,8 +2259,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { oldPRState: previousPRState, newPRState: session.lifecycle.pr.state, + // prNumber/prUrl kept for backward compat — drop in schemaVersion 3 prNumber: session.lifecycle.pr.number, prUrl: session.lifecycle.pr.url, + context, + schemaVersion: 2, }, }); await notifyHuman(prEvent, inferPriority(prEventType)); @@ -2102,6 +2289,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan await Promise.allSettled([ maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction), maybeDispatchMergeConflicts(session, newStatus), + maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction), ]); // Report watcher: audit agent reports for issues (#140) @@ -2178,7 +2366,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // Execute reaction if configured if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) { - await executeReaction(session.id, session.projectId, reactionKey, reactionConfig); + await executeReaction(session, reactionKey, reactionConfig); } } @@ -2248,7 +2436,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const reactionConfig = config.reactions[reactionKey]; if (reactionConfig && reactionConfig.action) { if (reactionConfig.auto !== false || reactionConfig.action === "notify") { - await executeReaction("system", "all", reactionKey, reactionConfig as ReactionConfig); + // Create a minimal session context for system events (no PR/issue context) + const systemSession: ReactionSessionContext = { + id: "system" as SessionId, + projectId: "all", + pr: null, + issueId: null, + branch: null, + metadata: {}, + agentInfo: null, + }; + await executeReaction(systemSession, reactionKey, reactionConfig as ReactionConfig); } } } diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index d244a176c..6e3891fb6 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -156,6 +156,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta status, tmuxName: raw["tmuxName"] as string | undefined, issue: raw["issue"] as string | undefined, + issueTitle: raw["issueTitle"] as string | undefined, pr: raw["pr"] as string | undefined, prAutoDetect: raw["prAutoDetect"] === "off" || raw["prAutoDetect"] === "false" || raw["prAutoDetect"] === false ? false : @@ -264,6 +265,7 @@ export function writeMetadata( if (metadata.tmuxName) data["tmuxName"] = metadata.tmuxName; if (metadata.issue) data["issue"] = metadata.issue; + if (metadata.issueTitle) data["issueTitle"] = metadata.issueTitle; if (metadata.pr) data["pr"] = metadata.pr; if (metadata.prAutoDetect !== undefined) data["prAutoDetect"] = metadata.prAutoDetect; if (metadata.summary) data["summary"] = metadata.summary; diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 636494e93..59be713e9 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1382,6 +1382,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM lifecycle, tmuxName, // Store tmux name for mapping issue: spawnConfig.issueId, + issueTitle: resolvedIssue?.title, // Store issue title for event enrichment project: spawnConfig.projectId, agent: selection.agentName, // Persist agent name for lifecycle manager createdAt: createdAt.toISOString(), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 036044c62..1dd5e0e90 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1678,6 +1678,7 @@ export interface SessionMetadata { lifecycle?: CanonicalSessionLifecycle; tmuxName?: string; // Tmux session name (matches session ID, e.g. "ao-1") issue?: string; + issueTitle?: string; // Issue title for event enrichment pr?: string; prAutoDetect?: boolean; summary?: string;