From 9ffb1bb6e60bcd163e53ff161af54bb1aff6c360 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <24b4506@iitb.ac.in> Date: Fri, 1 May 2026 15:19:09 +0530 Subject: [PATCH] feat(core): enrich events with PR title, description, and URL (#1326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: serialize ao start and stop numbered orchestrators (#1306) * fix: restore dead orchestrators on start (#1306) * fix: harden startup lock handling (#1306) * feat(core): enrich events with PR title, description, and URL Adds PR and issue context to all event payloads sent to notifiers. External consumers (Telegram, Discord, n8n) can now display meaningful information without making additional API calls. Changes: - Add buildEventContext() helper to extract PR/issue context from session - Enrich all createEvent() calls with context data (pr, issueId, issueTitle, branch) - Store issueTitle in session metadata during spawn - Add issueTitle field to SessionMetadata interface - Update executeReaction() to accept session for context access - Add tests for event enrichment The context includes: - pr: { url, title, number, branch } when PR exists - issueId: issue identifier - issueTitle: issue title (from tracker during spawn) - branch: session branch name Events before PR creation gracefully omit PR fields (pr: null). Existing webhook consumers that ignore unknown fields are unaffected. Closes #1226 Co-Authored-By: Claude Opus 4.5 * fix(core): address review comments for event enrichment - Add issueTitle to readMetadata/writeMetadata for proper persistence - Create ReactionSessionContext type for type-safe system events - Replace unsafe `as unknown as Session` cast with proper union type - Add end-to-end test verifying issueTitle persistence during spawn Co-Authored-By: Claude Opus 4.5 * fix(cli): include lock file path in startup lock error message Co-Authored-By: Claude Opus 4.6 * fix: address review feedback — fd safety, kill-all resilience, issueTitle restore - Restore try/catch/finally in tryAcquire for fd leak prevention - Wrap stop command's sm.list()+kill in try/catch so dashboard shutdown always runs even on session listing failure - Add per-iteration error handling in kill-all loop with partial failure reporting (spinner.warn for mixed results) - Unify allSessionPrefixes derivation between start and stop commands - Propagate issueTitle through archive restore path - Add clarifying comments on agentInfo.summary fallback and intentional prNumber/prUrl duplication in event data - Add ora warn mock for stop tests - Update changeset to minor (event enrichment is a feature) and add CLI changeset for stop resilience - Add tests for kill-all error mid-loop and issueTitle archive restore Co-Authored-By: Claude Opus 4.6 * fix: address harsh-batheja review — title/summary split, lock grace, context namespace - Separate PR title from agent summary in EventContext: title is null until enrichment cache populates; summary is a distinct field so webhook consumers never confuse the task summary for a PR title. - Restore UNPARSEABLE_LOCK_GRACE_MS (5s mtime grace) and isStaleUnparseableLock lost during merge conflict resolution — prevents lockfile-steal race when process A just created the file but hasn't written metadata yet. - Fix orchestrator sort: extract numeric suffix instead of localeCompare so -10 sorts after -2, not before. - Namespace context under data.context instead of spreading into data to prevent field collisions with reaction-specific keys. - Add schemaVersion: 2 to all enriched events so consumers can migrate away from top-level prNumber/prUrl (kept for compat, marked for removal in v3). - Update event enrichment tests for nested context structure. Co-Authored-By: Claude Opus 4.6 * fix: resolve merge conflicts — use formatReviewCommentsMessage, fix batch enrichment mock - Replace formatAutomatedCommentsMessage with upstream's formatReviewCommentsMessage for automated review comment dispatch (fixes type mismatch with ReviewComment[]) - Make createMockSCM's enrichSessionsPRBatch dynamically resolve from individual method mocks so test overrides (e.g. getPRState("closed")) propagate correctly - Add explicit enrichSessionsPRBatch to merge-conflict-tracking test to avoid unexpected getMergeability calls from the dynamic mock - Remove all debug console.log statements added during troubleshooting Co-Authored-By: Claude Opus 4.6 * fix: wire up maybeDispatchCIFailureDetails and record dispatch hash on transition The function was defined but never called after merge conflict resolution dropped the call site. Added it back to the Promise.allSettled alongside maybeDispatchReviewBacklog and maybeDispatchMergeConflicts. Also updated the transition-reaction early-return to record the dispatch hash, since the transition path now enriches the CI message with detailed check info from the batch cache — preventing duplicate sends on subsequent polls. Co-Authored-By: Claude Opus 4.6 * test: remove stale duplicate test from rebase Removes the orphaned numbered-orchestrator restoration test left over from feat/1226 history. Upstream's canonical model test (same name, expects "app-orchestrator" via ensureOrchestrator) supersedes it. Co-Authored-By: Claude Opus 4.7 * docs: remove misleading CLI CHANGELOG entry The "Restore the most recently active dead orchestrator on ao start" entry described upstream's ensureOrchestrator behavior (#1487), not work contributed by this PR. Co-Authored-By: Claude Opus 4.7 * docs(changeset): correct scope and drop stale CLI changeset - Rewrite the @aoagents/ao-core changeset to describe only what feat/1226 contributes: event enrichment with schemaVersion: 2, issueTitle persistence, executeReaction refactor, maybeDispatchCIFailureDetails, and bugbot-comments enrichment. Drop the false claims about adding spawn-target and format-automated-comments (those modules came from upstream PRs #1330 and #1334). - Delete stop-kill-all-resilience.md — its claims (kill-all loop, fd-safety in tryAcquire, allSessionPrefixes unify) are no longer in the branch after the rebase took upstream's canonical stop logic. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.5 --- .changeset/fix-bugbot-detail-dispatch.md | 13 +- packages/cli/src/lib/running-state.ts | 28 +- .../src/__tests__/lifecycle-manager.test.ts | 141 ++++++++++ .../__tests__/session-manager/spawn.test.ts | 41 +++ packages/core/src/__tests__/test-utils.ts | 19 +- packages/core/src/lifecycle-manager.ts | 264 +++++++++++++++--- packages/core/src/metadata.ts | 2 + packages/core/src/session-manager.ts | 1 + packages/core/src/types.ts | 1 + 9 files changed, 447 insertions(+), 63 deletions(-) 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;