feat(core): enrich events with PR title, description, and URL (#1326)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(cli): include lock file path in startup lock error message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-01 15:19:09 +05:30 committed by GitHub
parent 00176abbd1
commit 9ffb1bb6e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 447 additions and 63 deletions

View File

@ -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`.

View File

@ -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<RunningState | null> {
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<boolean> {
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);
}
/**

View File

@ -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,
}),
}),
);
});
});

View File

@ -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",

View File

@ -204,13 +204,20 @@ export function createMockSCM(overrides: Partial<SCM> = {}): 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;

View File

@ -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<string, string>;
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<string, PREnrichmentData>,
): 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<ReactionResult> {
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<void> {
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
const scm = project.scm?.plugin ? registry.get<SCM>("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);
}
}
}

View File

@ -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;

View File

@ -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(),

View File

@ -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;