From cab0a2656d9d457d55c42122694e1273d81294dc Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 16 Apr 2026 23:56:01 +0530 Subject: [PATCH 1/3] feat(core): add explicit agent reporting for workflow coordination (Stage 3) Introduce an explicit reporting channel so worker agents can self-declare their workflow phase (started/working/waiting/needs-input/fixing-ci/ addressing-reviews/completed). Fresh reports are trusted over weak inference but runtime death, activity-based waiting_input, and SCM ground truth still take precedence. - Add `applyAgentReport`, validator, canonical mapping, and freshness helper in `packages/core/src/agent-report.ts`. - Wire the fallback into `determineStatus` just before the idle-beyond- threshold promotion, skipping orchestrator and terminal sessions. - Add `ao acknowledge` and `ao report ` CLI commands. Both resolve the session from `AO_SESSION_ID` when no argument is passed. - Teach the base agent prompt and orchestrator prompt about the new reporting commands. - Ship unit tests covering normalization, mapping, transition validation, metadata persistence, freshness, and first-start behavior. Stage 3 of the state-machine redesign (see aa-2/state-machine-redesign- rollout-plan.md). --- packages/cli/src/commands/report.ts | 116 +++++++ packages/cli/src/program.ts | 3 + .../core/src/__tests__/agent-report.test.ts | 314 ++++++++++++++++++ packages/core/src/agent-report.ts | 273 +++++++++++++++ packages/core/src/index.ts | 20 ++ packages/core/src/lifecycle-manager.ts | 24 ++ packages/core/src/prompt-builder.ts | 23 ++ packages/core/src/prompts/orchestrator.md | 8 + 8 files changed, 781 insertions(+) create mode 100644 packages/cli/src/commands/report.ts create mode 100644 packages/core/src/__tests__/agent-report.test.ts create mode 100644 packages/core/src/agent-report.ts diff --git a/packages/cli/src/commands/report.ts b/packages/cli/src/commands/report.ts new file mode 100644 index 000000000..bd277d1dc --- /dev/null +++ b/packages/cli/src/commands/report.ts @@ -0,0 +1,116 @@ +/** + * `ao acknowledge` and `ao report` — explicit agent reporting commands (Stage 3). + * + * These commands are invoked by the worker agent from inside its managed + * session to declare workflow transitions (started / waiting / needs-input / + * fixing-ci / addressing-reviews / completed). + * + * Both commands resolve the session from: + * 1. Explicit `--session` / positional argument, OR + * 2. the `AO_SESSION_ID` environment variable set by every agent plugin. + * + * The lifecycle manager prefers fresh reports over weak inference but runtime + * evidence (process death, merged PR) still overrides — see + * `packages/core/src/agent-report.ts` for the fallback matrix. + */ + +import chalk from "chalk"; +import type { Command } from "commander"; +import { + AGENT_REPORTED_STATES, + applyAgentReport, + getSessionsDir, + loadConfig, + normalizeAgentReportedState, + type AgentReportedState, +} from "@aoagents/ao-core"; +import { getSessionManager } from "../lib/create-session-manager.js"; + +function resolveSessionId(explicit: string | undefined): string { + const fromArg = explicit?.trim(); + if (fromArg) return fromArg; + const fromEnv = process.env["AO_SESSION_ID"]?.trim(); + if (fromEnv) return fromEnv; + console.error( + chalk.red( + "No session provided. Pass a session name or set AO_SESSION_ID (set automatically inside managed sessions).", + ), + ); + process.exit(1); +} + +async function writeReport( + sessionName: string, + state: AgentReportedState, + note: string | undefined, +): Promise { + const config = loadConfig(); + const sm = await getSessionManager(config); + const session = await sm.get(sessionName); + if (!session) { + console.error(chalk.red(`Session not found: ${sessionName}`)); + process.exit(1); + } + const project = config.projects[session.projectId]; + if (!project) { + console.error(chalk.red(`Project not found for session: ${sessionName}`)); + process.exit(1); + } + const sessionsDir = getSessionsDir(config.configPath, project.path); + try { + const result = applyAgentReport(sessionsDir, sessionName, { state, note }); + const label = + result.previousState === result.nextState + ? chalk.dim(`(${result.nextState})`) + : chalk.dim(`(${result.previousState} → ${result.nextState})`); + console.log( + `${chalk.green("✓")} ${chalk.bold(sessionName)} reported ${chalk.cyan(state)} ${label}`, + ); + if (note) { + console.log(chalk.dim(` note: ${note}`)); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`Report rejected: ${message}`)); + process.exit(1); + } +} + +export function registerAcknowledge(program: Command): void { + program + .command("acknowledge") + .description( + "Acknowledge session pickup — agents run this once after reading the initial prompt (Stage 3).", + ) + .argument("[session]", "Session ID (defaults to AO_SESSION_ID)") + .option("--note ", "Optional brief note to include with the acknowledgment") + .action(async (session: string | undefined, opts: { note?: string }) => { + const sessionId = resolveSessionId(session); + await writeReport(sessionId, "started", opts.note); + }); +} + +export function registerReport(program: Command): void { + const allowed = AGENT_REPORTED_STATES.join(", "); + program + .command("report") + .description( + `Declare a workflow transition (Stage 3). Allowed states: ${allowed} (hyphenated aliases accepted).`, + ) + .argument("", `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, ...)`) + .option("-s, --session ", "Session ID (defaults to AO_SESSION_ID)") + .option("--note ", "Optional brief note to include with the report") + .action(async (state: string, opts: { session?: string; note?: string }) => { + const canonical = normalizeAgentReportedState(state); + if (!canonical) { + console.error( + chalk.red( + `Unknown state: ${state}. Allowed: ${allowed} (or aliases: fixing-ci, addressing-reviews, needs-input).`, + ), + ); + process.exit(1); + } + const sessionId = resolveSessionId(opts.session); + await writeReport(sessionId, canonical, opts.note); + }); +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 9e1a7152b..327c8329e 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -4,6 +4,7 @@ import { registerStatus } from "./commands/status.js"; import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; import { registerSession } from "./commands/session.js"; import { registerSend } from "./commands/send.js"; +import { registerAcknowledge, registerReport } from "./commands/report.js"; import { registerReviewCheck } from "./commands/review-check.js"; import { registerDashboard } from "./commands/dashboard.js"; import { registerOpen } from "./commands/open.js"; @@ -32,6 +33,8 @@ export function createProgram(): Command { registerBatchSpawn(program); registerSession(program); registerSend(program); + registerAcknowledge(program); + registerReport(program); registerReviewCheck(program); registerDashboard(program); registerOpen(program); diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts new file mode 100644 index 000000000..beb62c6db --- /dev/null +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { + AGENT_REPORTED_STATES, + AGENT_REPORT_FRESHNESS_MS, + AGENT_REPORT_METADATA_KEYS, + applyAgentReport, + isAgentReportFresh, + mapAgentReportToLifecycle, + normalizeAgentReportedState, + readAgentReport, + validateAgentReportTransition, +} from "../agent-report.js"; +import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js"; +import { createInitialCanonicalLifecycle } from "../lifecycle-state.js"; +import type { CanonicalSessionLifecycle } from "../types.js"; + +let dataDir: string; + +beforeEach(() => { + dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`); + mkdirSync(dataDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +function seedWorkerSession( + sessionId: string, + init?: Partial, +): CanonicalSessionLifecycle { + const lifecycle = createInitialCanonicalLifecycle("worker"); + // Default the seeded lifecycle to "working" with an existing startedAt so + // tests exercise the transition-applied path (not the first-start path). + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z"; + if (init) { + Object.assign(lifecycle.session, init); + } + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + writeMetadata(dataDir, sessionId, { + worktree: "/tmp/worktree", + branch: "feat/x", + status: "working", + project: "demo", + }); + writeCanonicalLifecycle(dataDir, sessionId, lifecycle); + return lifecycle; +} + +describe("normalizeAgentReportedState", () => { + it("accepts canonical values", () => { + for (const state of AGENT_REPORTED_STATES) { + expect(normalizeAgentReportedState(state)).toBe(state); + } + }); + + it("accepts hyphen and short aliases", () => { + expect(normalizeAgentReportedState("needs-input")).toBe("needs_input"); + expect(normalizeAgentReportedState("fixing-ci")).toBe("fixing_ci"); + expect(normalizeAgentReportedState("addressing-reviews")).toBe("addressing_reviews"); + expect(normalizeAgentReportedState("ci")).toBe("fixing_ci"); + expect(normalizeAgentReportedState("reviews")).toBe("addressing_reviews"); + expect(normalizeAgentReportedState("done")).toBe("completed"); + expect(normalizeAgentReportedState("complete")).toBe("completed"); + expect(normalizeAgentReportedState("input")).toBe("needs_input"); + expect(normalizeAgentReportedState("start")).toBe("started"); + expect(normalizeAgentReportedState("work")).toBe("working"); + expect(normalizeAgentReportedState("wait")).toBe("waiting"); + }); + + it("is case-insensitive and trims whitespace", () => { + expect(normalizeAgentReportedState(" WAITING ")).toBe("waiting"); + expect(normalizeAgentReportedState("Needs-Input")).toBe("needs_input"); + }); + + it("returns null for unknown values", () => { + expect(normalizeAgentReportedState("foo")).toBeNull(); + expect(normalizeAgentReportedState("")).toBeNull(); + }); +}); + +describe("mapAgentReportToLifecycle", () => { + it("maps every reportable state to a canonical pair", () => { + for (const state of AGENT_REPORTED_STATES) { + const mapped = mapAgentReportToLifecycle(state); + expect(mapped.sessionState).toBeTypeOf("string"); + expect(mapped.sessionReason).toBeTypeOf("string"); + } + }); + + it("maps needs_input to the canonical needs_input state", () => { + expect(mapAgentReportToLifecycle("needs_input")).toEqual({ + sessionState: "needs_input", + sessionReason: "awaiting_user_input", + }); + }); + + it("maps waiting and completed to idle (non-terminal)", () => { + expect(mapAgentReportToLifecycle("waiting").sessionState).toBe("idle"); + expect(mapAgentReportToLifecycle("completed").sessionState).toBe("idle"); + }); + + it("maps fixing_ci and addressing_reviews to working with the right reason", () => { + expect(mapAgentReportToLifecycle("fixing_ci")).toEqual({ + sessionState: "working", + sessionReason: "fixing_ci", + }); + expect(mapAgentReportToLifecycle("addressing_reviews")).toEqual({ + sessionState: "working", + sessionReason: "resolving_review_comments", + }); + }); +}); + +describe("validateAgentReportTransition", () => { + it("rejects orchestrator sessions", () => { + const lifecycle = createInitialCanonicalLifecycle("orchestrator"); + const result = validateAgentReportTransition(lifecycle, "working"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/orchestrator/); + }); + + it("rejects terminated sessions", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "terminated"; + const result = validateAgentReportTransition(lifecycle, "working"); + expect(result.ok).toBe(false); + }); + + it("rejects when session is done (unless next=completed)", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "done"; + expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false); + expect(validateAgentReportTransition(lifecycle, "completed").ok).toBe(true); + }); + + it("rejects reports on merged PRs", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.pr.state = "merged"; + expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false); + }); + + it("rejects reports when runtime is missing or exited", () => { + const missing = createInitialCanonicalLifecycle("worker"); + missing.runtime.state = "missing"; + expect(validateAgentReportTransition(missing, "working").ok).toBe(false); + + const exited = createInitialCanonicalLifecycle("worker"); + exited.runtime.state = "exited"; + expect(validateAgentReportTransition(exited, "working").ok).toBe(false); + }); + + it("accepts valid worker transitions", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "working"; + lifecycle.runtime.state = "alive"; + expect(validateAgentReportTransition(lifecycle, "fixing_ci").ok).toBe(true); + expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(true); + }); +}); + +describe("applyAgentReport", () => { + const sessionId = "demo-1"; + + beforeEach(() => { + seedWorkerSession(sessionId); + }); + + it("writes canonical lifecycle and metadata keys", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + const result = applyAgentReport(dataDir, sessionId, { + state: "needs_input", + note: " please clarify the spec ", + now, + }); + + expect(result.report.state).toBe("needs_input"); + expect(result.report.timestamp).toBe(now.toISOString()); + expect(result.report.note).toBe("please clarify the spec"); + expect(result.nextState).toBe("needs_input"); + + const meta = readMetadataRaw(dataDir, sessionId); + expect(meta).not.toBeNull(); + expect(meta![AGENT_REPORT_METADATA_KEYS.STATE]).toBe("needs_input"); + expect(meta![AGENT_REPORT_METADATA_KEYS.AT]).toBe(now.toISOString()); + expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE]).toBe("please clarify the spec"); + }); + + it("sets startedAt on the first working transition", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + // Re-seed with startedAt explicitly null so we exercise the first-start + // branch of applyAgentReport. + seedWorkerSession(sessionId, { + state: "not_started", + reason: "spawn_requested", + startedAt: null, + }); + applyAgentReport(dataDir, sessionId, { state: "started", now }); + const meta = readMetadataRaw(dataDir, sessionId); + expect(meta).not.toBeNull(); + // The canonical payload is stored in statePayload as JSON. + const payload = JSON.parse(meta!["statePayload"]); + expect(payload.session.state).toBe("working"); + expect(payload.session.reason).toBe("agent_acknowledged"); + expect(payload.session.startedAt).toBe(now.toISOString()); + }); + + it("clears a previous note when none is supplied", () => { + applyAgentReport(dataDir, sessionId, { + state: "working", + note: "first note", + now: new Date("2025-01-01T11:00:00.000Z"), + }); + applyAgentReport(dataDir, sessionId, { + state: "working", + now: new Date("2025-01-01T12:00:00.000Z"), + }); + const meta = readMetadataRaw(dataDir, sessionId); + expect(meta).not.toBeNull(); + expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE] ?? "").toBe(""); + }); + + it("throws when the transition is rejected", () => { + // Force lifecycle into a terminated state and try to re-report. + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "terminated"; + writeCanonicalLifecycle(dataDir, sessionId, lifecycle); + expect(() => + applyAgentReport(dataDir, sessionId, { + state: "working", + now: new Date(), + }), + ).toThrow(/terminated/); + }); + + it("throws when the session does not exist", () => { + expect(() => + applyAgentReport(dataDir, "missing-session", { + state: "working", + now: new Date(), + }), + ).toThrow(/not found/); + }); +}); + +describe("readAgentReport + isAgentReportFresh", () => { + it("returns null when metadata lacks report keys", () => { + expect(readAgentReport({})).toBeNull(); + expect(readAgentReport(null)).toBeNull(); + expect(readAgentReport(undefined)).toBeNull(); + }); + + it("returns null for unknown states or bad timestamps", () => { + expect( + readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "not-a-state", + [AGENT_REPORT_METADATA_KEYS.AT]: new Date().toISOString(), + }), + ).toBeNull(); + expect( + readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: "not-a-timestamp", + }), + ).toBeNull(); + }); + + it("parses a valid report with note", () => { + const at = "2025-01-01T00:00:00.000Z"; + const report = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "fixing_ci", + [AGENT_REPORT_METADATA_KEYS.AT]: at, + [AGENT_REPORT_METADATA_KEYS.NOTE]: "still debugging", + }); + expect(report).toEqual({ state: "fixing_ci", timestamp: at, note: "still debugging" }); + }); + + it("treats an empty note as absent", () => { + const at = "2025-01-01T00:00:00.000Z"; + const report = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: at, + [AGENT_REPORT_METADATA_KEYS.NOTE]: "", + }); + expect(report?.note).toBeUndefined(); + }); + + it("reports freshness against the default window", () => { + const now = new Date("2025-01-01T12:05:00.000Z"); + const freshAt = "2025-01-01T12:04:00.000Z"; // 1m old + const staleAt = "2025-01-01T11:55:00.000Z"; // 10m old + const fresh = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: freshAt, + })!; + const stale = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: staleAt, + })!; + expect(isAgentReportFresh(fresh, now)).toBe(true); + expect(isAgentReportFresh(stale, now)).toBe(false); + }); + + it("exposes the default freshness window (5 minutes)", () => { + expect(AGENT_REPORT_FRESHNESS_MS).toBe(5 * 60 * 1000); + }); +}); diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts new file mode 100644 index 000000000..a5b77dbf0 --- /dev/null +++ b/packages/core/src/agent-report.ts @@ -0,0 +1,273 @@ +/** + * Agent Report — explicit workflow transitions declared by the worker agent. + * + * Stage 3 of the state-machine redesign. Agents run `ao acknowledge` and + * `ao report ` from inside a managed session to declare the workflow + * phase they are entering. The lifecycle manager prefers fresh agent reports + * over weak inference, but runtime evidence (process death, merged PR) and + * SCM ground-truth (CI, review decisions) still take precedence. + * + * Fallback matrix (highest precedence first): + * 1. Runtime dead + no recent activity → terminated/stuck + * 2. Agent activity plugin surfaces waiting_input/exited + * 3. SCM/PR ground truth (merged, closed, CI, reviews) + * 4. Fresh agent report (this module) + * 5. Idle-beyond-threshold promotion → stuck + * 6. Default to working + */ + +import type { + CanonicalSessionLifecycle, + CanonicalSessionReason, + CanonicalSessionState, + SessionId, + SessionStatus, +} from "./types.js"; +import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js"; +import { deriveLegacyStatus } from "./lifecycle-state.js"; + +/** + * Canonical set of states an agent can self-declare. + * + * - `started` — agent has begun the task after planning + * - `working` — generic working signal, useful after a pause + * - `waiting` — blocked on an external dependency agent cannot unblock + * - `needs_input` — blocked on human input + * - `fixing_ci` — responding to a failing CI run + * - `addressing_reviews`— responding to requested review changes + * - `completed` — finished research/non-coding work (not "merged") + * + * Note: agents cannot self-report `done`, `terminated`, or PR-state transitions. + * Those remain owned by AO so ground-truth sources (SCM, runtime) stay + * authoritative. + */ +export const AGENT_REPORTED_STATES = [ + "started", + "working", + "waiting", + "needs_input", + "fixing_ci", + "addressing_reviews", + "completed", +] as const; + +export type AgentReportedState = (typeof AGENT_REPORTED_STATES)[number]; + +export interface AgentReport { + state: AgentReportedState; + /** ISO 8601 timestamp — when the agent issued the report. */ + timestamp: string; + /** Optional free-text note the agent may include (e.g. brief status line). */ + note?: string; +} + +/** Metadata keys written by `applyAgentReport`. Keep in sync with CLI parsing. */ +export const AGENT_REPORT_METADATA_KEYS = { + STATE: "agentReportedState", + AT: "agentReportedAt", + NOTE: "agentReportedNote", +} as const; + +/** Freshness window — agent reports older than this are ignored. */ +export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes + +/** CLI surface accepts these hyphen/underscore aliases for convenience. */ +const INPUT_ALIASES: Record = { + "start": "started", + "started": "started", + "working": "working", + "work": "working", + "wait": "waiting", + "waiting": "waiting", + "needs-input": "needs_input", + "needs_input": "needs_input", + "input": "needs_input", + "fixing-ci": "fixing_ci", + "fixing_ci": "fixing_ci", + "ci": "fixing_ci", + "addressing-reviews": "addressing_reviews", + "addressing_reviews": "addressing_reviews", + "reviews": "addressing_reviews", + "completed": "completed", + "complete": "completed", + "done": "completed", +}; + +/** Normalize a user-supplied report name into the canonical form. */ +export function normalizeAgentReportedState(input: string): AgentReportedState | null { + if (!input) return null; + return INPUT_ALIASES[input.trim().toLowerCase()] ?? null; +} + +/** Canonical mapping: AgentReportedState → (canonical session state, reason). */ +export function mapAgentReportToLifecycle(state: AgentReportedState): { + sessionState: CanonicalSessionState; + sessionReason: CanonicalSessionReason; +} { + switch (state) { + case "started": + return { sessionState: "working", sessionReason: "agent_acknowledged" }; + case "working": + return { sessionState: "working", sessionReason: "task_in_progress" }; + case "waiting": + return { sessionState: "idle", sessionReason: "awaiting_external_review" }; + case "needs_input": + return { sessionState: "needs_input", sessionReason: "awaiting_user_input" }; + case "fixing_ci": + return { sessionState: "working", sessionReason: "fixing_ci" }; + case "addressing_reviews": + return { sessionState: "working", sessionReason: "resolving_review_comments" }; + case "completed": + return { sessionState: "idle", sessionReason: "research_complete" }; + } +} + +export interface AgentReportTransitionResult { + ok: boolean; + reason?: string; +} + +/** + * Validate whether an agent-issued report is allowed given the current lifecycle. + * + * Rules: + * - Orchestrator sessions cannot accept agent reports (orchestrator sessions + * are read-only coordinators). + * - Terminal states (`done`, `terminated`) cannot be re-opened by an agent. + * - Merged PRs cannot be re-opened by an agent (`completed`/`working` etc. + * attempts are rejected). + * - Runtime state of `missing`/`exited` means the agent cannot possibly be + * reporting — reject so we don't silently contradict runtime truth. + */ +export function validateAgentReportTransition( + lifecycle: CanonicalSessionLifecycle, + next: AgentReportedState, +): AgentReportTransitionResult { + if (lifecycle.session.kind === "orchestrator") { + return { ok: false, reason: "orchestrator sessions cannot self-report" }; + } + if (lifecycle.session.state === "terminated") { + return { ok: false, reason: "session is terminated" }; + } + if (lifecycle.session.state === "done" && next !== "completed") { + return { ok: false, reason: "session is already done" }; + } + if (lifecycle.pr.state === "merged") { + return { ok: false, reason: "PR already merged" }; + } + if (lifecycle.runtime.state === "missing" || lifecycle.runtime.state === "exited") { + return { ok: false, reason: "runtime is not alive" }; + } + return { ok: true }; +} + +export interface ApplyAgentReportInput { + state: AgentReportedState; + note?: string; + /** Override the current clock — used by tests. */ + now?: Date; +} + +export interface ApplyAgentReportResult { + report: AgentReport; + legacyStatus: SessionStatus; + previousState: CanonicalSessionState; + nextState: CanonicalSessionState; +} + +/** + * Apply an agent report to a session: update the canonical lifecycle on disk + * and persist the report metadata keys. Throws when the transition is rejected. + * + * The write is idempotent: applying the same report twice is safe (lifecycle + * fields are already set, metadata timestamp refreshes). + */ +export function applyAgentReport( + dataDir: string, + sessionId: SessionId, + input: ApplyAgentReportInput, +): ApplyAgentReportResult { + const raw = readMetadataRaw(dataDir, sessionId); + if (!raw) { + throw new Error(`Session not found: ${sessionId}`); + } + + const now = (input.now ?? new Date()).toISOString(); + let previousState: CanonicalSessionState | null = null; + let nextState: CanonicalSessionState | null = null; + let legacyStatus: SessionStatus | null = null; + + const nextLifecycle = updateCanonicalLifecycle( + dataDir, + sessionId, + (current) => { + const validation = validateAgentReportTransition(current, input.state); + if (!validation.ok) { + throw new Error(validation.reason ?? "transition rejected"); + } + const mapped = mapAgentReportToLifecycle(input.state); + previousState = current.session.state; + nextState = mapped.sessionState; + current.session.state = mapped.sessionState; + current.session.reason = mapped.sessionReason; + current.session.lastTransitionAt = now; + if (mapped.sessionState === "working" && current.session.startedAt === null) { + current.session.startedAt = now; + } + legacyStatus = deriveLegacyStatus(current); + return current; + }, + ); + + if (!nextLifecycle || !previousState || !nextState || !legacyStatus) { + throw new Error(`Failed to apply agent report for session ${sessionId}`); + } + + // Persist report metadata alongside the lifecycle patch. + const metadataUpdates: Record = { + [AGENT_REPORT_METADATA_KEYS.STATE]: input.state, + [AGENT_REPORT_METADATA_KEYS.AT]: now, + }; + if (input.note && input.note.trim()) { + metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = input.note.trim(); + } else { + // Clear stale notes from previous reports so they don't mislead humans. + metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = ""; + } + updateMetadata(dataDir, sessionId, metadataUpdates); + + return { + report: { state: input.state, timestamp: now, note: input.note?.trim() || undefined }, + legacyStatus, + previousState, + nextState, + }; +} + +/** Read an agent report out of a session's raw metadata, or null if absent. */ +export function readAgentReport(meta: Record | null | undefined): AgentReport | null { + if (!meta) return null; + const state = meta[AGENT_REPORT_METADATA_KEYS.STATE]; + const at = meta[AGENT_REPORT_METADATA_KEYS.AT]; + if (!state || !at) return null; + if (!AGENT_REPORTED_STATES.includes(state as AgentReportedState)) return null; + const parsed = Date.parse(at); + if (Number.isNaN(parsed)) return null; + const note = meta[AGENT_REPORT_METADATA_KEYS.NOTE]; + return { + state: state as AgentReportedState, + timestamp: new Date(parsed).toISOString(), + note: note && note.length > 0 ? note : undefined, + }; +} + +/** Check whether an agent report is fresh (within the freshness window). */ +export function isAgentReportFresh( + report: AgentReport, + now: Date = new Date(), + windowMs: number = AGENT_REPORT_FRESHNESS_MS, +): boolean { + const reportedAt = Date.parse(report.timestamp); + if (Number.isNaN(reportedAt)) return false; + return now.getTime() - reportedAt <= windowMs; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 191dcf636..5c0897e8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,26 @@ export { } from "./metadata.js"; export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; +// Agent reports — explicit workflow transitions declared by worker agents (Stage 3) +export { + AGENT_REPORTED_STATES, + AGENT_REPORT_METADATA_KEYS, + AGENT_REPORT_FRESHNESS_MS, + applyAgentReport, + readAgentReport, + isAgentReportFresh, + mapAgentReportToLifecycle, + normalizeAgentReportedState, + validateAgentReportTransition, +} from "./agent-report.js"; +export type { + AgentReport, + AgentReportedState, + ApplyAgentReportInput, + ApplyAgentReportResult, + AgentReportTransitionResult, +} from "./agent-report.js"; + // tmux — command wrappers export { isTmuxAvailable, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index d88cc068e..6c1a5d602 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -39,6 +39,11 @@ import { import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; +import { + isAgentReportFresh, + mapAgentReportToLifecycle, + readAgentReport, +} from "./agent-report.js"; import { createCorrelationId, createProjectObserver } from "./observability.js"; import { resolveNotifierTarget } from "./notifier-resolution.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; @@ -783,6 +788,25 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + // Fresh agent reports outrank weak inference (idle-beyond-threshold / + // default-to-working) but runtime death, activity waiting_input, and SCM + // ground truth already short-circuited above. Orchestrator sessions and + // terminal states are skipped intentionally. + const agentReport = readAgentReport(session.metadata); + if ( + agentReport && + isAgentReportFresh(agentReport) && + session.metadata["role"] !== "orchestrator" && + !session.id.endsWith("-orchestrator") && + lifecycle.session.state !== "terminated" && + lifecycle.session.state !== "done" + ) { + const mapped = mapAgentReportToLifecycle(agentReport.state); + setSessionState(mapped.sessionState, mapped.sessionReason); + const legacy = deriveLegacyStatus(lifecycle, session.status); + return commit(legacy, `agent_report:${agentReport.state}`, 0); + } + if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) { setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure"); return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0); diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index 8d7aefbe0..c16ae8697 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -27,6 +27,22 @@ export const BASE_AGENT_PROMPT = `You are an AI coding agent managed by the Agen - If CI fails, the orchestrator will send you the failures — fix them and push again. - If reviewers request changes, the orchestrator will forward their comments — address each one, push fixes, and reply to the comments. +## Reporting Progress to AO +The orchestrator infers your status from runtime signals, but explicit reports are always preferred — they are accurate and fresh. Run these commands from the session shell (AO_SESSION_ID is pre-set for you): + +- \`ao acknowledge\` — run once after reading the initial task so AO knows you picked it up. +- \`ao report working\` — declare you are actively making progress (useful after pauses or long thinking blocks). +- \`ao report waiting\` — you are blocked on something AO cannot unblock on its own (e.g. waiting for a human, external service). +- \`ao report needs-input\` — you need a decision or info from the human before proceeding. +- \`ao report fixing-ci\` — you are working specifically on making CI green again. +- \`ao report addressing-reviews\` — you are working on reviewer-requested changes. +- \`ao report completed\` — you finished non-coding research or analysis work that doesn't produce a PR. + +Rules: +- Do NOT self-report \`done\`, \`terminated\`, or any PR-merge state — AO owns those transitions via SCM ground truth. +- A fresh report is trusted over weak inference but runtime death, activity-based waiting_input, and SCM events (merged/closed PR, CI failure, reviews) still take precedence. +- Use \`--note ""\` to attach a short rationale when the state change is non-obvious. + ## Git Workflow - Always create a feature branch from the default branch (never commit directly to it). - Use conventional commit messages (feat:, fix:, chore:, etc.). @@ -46,6 +62,13 @@ export const BASE_AGENT_PROMPT_NO_REPO = `You are an AI coding agent managed by - You are running inside a managed session. Focus on the assigned task. - No remote repository is configured — work locally. PR, CI, and review features are unavailable. +## Reporting Progress to AO +Explicit reports help the orchestrator track your state accurately. Run these from the session shell (AO_SESSION_ID is pre-set): +- \`ao acknowledge\` — run once after reading the initial task. +- \`ao report working\` / \`waiting\` / \`needs-input\` — declare your current phase. +- \`ao report completed\` — finish non-coding research or analysis work. +Do NOT self-report \`done\` or \`terminated\` — AO owns those transitions. + ## Git Workflow - Always create a feature branch from the default branch (never commit directly to it). - Use conventional commit messages (feat:, fix:, chore:, etc.).`; diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 941fda018..64a0f3b42 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -101,6 +101,14 @@ Use `ao status` to see: - Unresolved comments count {{REPO_CONFIGURED_SECTION_END}} +### Explicit Agent Reports + +Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report ` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth. + +- Never run `ao acknowledge` or `ao report` from the orchestrator session - they are worker-only commands. +- Fresh reports (<5 min) are useful hints when inference is weak, but runtime death, activity-based waiting_input, and SCM truth (merged/closed PR, CI failure, review decisions) still take precedence. +- If an agent reports `waiting` but a PR actually merged, trust the PR state and follow up. + ### Sending Messages Send instructions to a running agent: From 593eec441a7719b3fb04d1308c3e2f8598c49f13 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 10:00:48 +0530 Subject: [PATCH 2/3] fix(core): tighten agent-report contract per PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review feedback on PR #117: - Drop the `done` alias from `INPUT_ALIASES`. Agents cannot self-report the terminal `done` state (AO owns that transition via SCM ground truth), so silently aliasing `done` → `completed` contradicted the prompts and weakened the reserved-transition boundary. - Tighten `validateAgentReportTransition` to reject ALL reports when `session.state === "done"`. Previously `completed` slipped through, but `completed` maps to `idle` and would have re-opened a terminal session, contradicting the function's own doc comment. - Reject future timestamps in `isAgentReportFresh`. A skewed/future `agentReportedAt` would otherwise yield a negative delta that satisfies `<= windowMs`, making the report appear "fresh" indefinitely and overriding stronger inference signals. Tests updated: - Replace done→completed alias assertion with explicit null expectation. - Expand `done`-state rejection test to cover `completed` and `needs_input`. - Add freshness test for future-dated timestamps. Co-Authored-By: Claude --- .../core/src/__tests__/agent-report.test.ts | 22 ++++++++++++--- packages/core/src/agent-report.ts | 27 ++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index beb62c6db..2ce36b6b7 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -67,7 +67,6 @@ describe("normalizeAgentReportedState", () => { expect(normalizeAgentReportedState("addressing-reviews")).toBe("addressing_reviews"); expect(normalizeAgentReportedState("ci")).toBe("fixing_ci"); expect(normalizeAgentReportedState("reviews")).toBe("addressing_reviews"); - expect(normalizeAgentReportedState("done")).toBe("completed"); expect(normalizeAgentReportedState("complete")).toBe("completed"); expect(normalizeAgentReportedState("input")).toBe("needs_input"); expect(normalizeAgentReportedState("start")).toBe("started"); @@ -75,6 +74,10 @@ describe("normalizeAgentReportedState", () => { expect(normalizeAgentReportedState("wait")).toBe("waiting"); }); + it("does not alias `done` (agents cannot self-report terminal done)", () => { + expect(normalizeAgentReportedState("done")).toBeNull(); + }); + it("is case-insensitive and trims whitespace", () => { expect(normalizeAgentReportedState(" WAITING ")).toBe("waiting"); expect(normalizeAgentReportedState("Needs-Input")).toBe("needs_input"); @@ -134,11 +137,14 @@ describe("validateAgentReportTransition", () => { expect(result.ok).toBe(false); }); - it("rejects when session is done (unless next=completed)", () => { + it("rejects all reports when session is done (terminal state cannot reopen)", () => { const lifecycle = createInitialCanonicalLifecycle("worker"); lifecycle.session.state = "done"; + // `completed` maps back to `idle` and would reanimate a `done` session, so + // it must also be rejected — not just the obvious working/needs_input ones. expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false); - expect(validateAgentReportTransition(lifecycle, "completed").ok).toBe(true); + expect(validateAgentReportTransition(lifecycle, "completed").ok).toBe(false); + expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(false); }); it("rejects reports on merged PRs", () => { @@ -308,6 +314,16 @@ describe("readAgentReport + isAgentReportFresh", () => { expect(isAgentReportFresh(stale, now)).toBe(false); }); + it("rejects future timestamps (clock skew must not appear forever-fresh)", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future + const future = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: futureAt, + })!; + expect(isAgentReportFresh(future, now)).toBe(false); + }); + it("exposes the default freshness window (5 minutes)", () => { expect(AGENT_REPORT_FRESHNESS_MS).toBe(5 * 60 * 1000); }); diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index a5b77dbf0..9611755bf 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -71,7 +71,13 @@ export const AGENT_REPORT_METADATA_KEYS = { /** Freshness window — agent reports older than this are ignored. */ export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes -/** CLI surface accepts these hyphen/underscore aliases for convenience. */ +/** + * CLI surface accepts these hyphen/underscore aliases for convenience. + * + * Note: `done` is intentionally NOT an alias — agents cannot self-report + * terminal `done` state (AO owns that transition via SCM ground truth). Use + * `completed` for finished non-coding research/analysis work. + */ const INPUT_ALIASES: Record = { "start": "started", "started": "started", @@ -90,7 +96,6 @@ const INPUT_ALIASES: Record = { "reviews": "addressing_reviews", "completed": "completed", "complete": "completed", - "done": "completed", }; /** Normalize a user-supplied report name into the canonical form. */ @@ -141,7 +146,7 @@ export interface AgentReportTransitionResult { */ export function validateAgentReportTransition( lifecycle: CanonicalSessionLifecycle, - next: AgentReportedState, + _next: AgentReportedState, ): AgentReportTransitionResult { if (lifecycle.session.kind === "orchestrator") { return { ok: false, reason: "orchestrator sessions cannot self-report" }; @@ -149,7 +154,9 @@ export function validateAgentReportTransition( if (lifecycle.session.state === "terminated") { return { ok: false, reason: "session is terminated" }; } - if (lifecycle.session.state === "done" && next !== "completed") { + // Terminal states cannot be re-opened by an agent — including `completed`, + // which maps back to `idle` and would otherwise reanimate a `done` session. + if (lifecycle.session.state === "done") { return { ok: false, reason: "session is already done" }; } if (lifecycle.pr.state === "merged") { @@ -261,7 +268,13 @@ export function readAgentReport(meta: Record | null | undefined) }; } -/** Check whether an agent report is fresh (within the freshness window). */ +/** + * Check whether an agent report is fresh (within the freshness window). + * + * Future timestamps (clock skew, malformed input) are rejected — otherwise a + * single skewed `agentReportedAt` could appear "fresh" indefinitely and + * override stronger inference signals. + */ export function isAgentReportFresh( report: AgentReport, now: Date = new Date(), @@ -269,5 +282,7 @@ export function isAgentReportFresh( ): boolean { const reportedAt = Date.parse(report.timestamp); if (Number.isNaN(reportedAt)) return false; - return now.getTime() - reportedAt <= windowMs; + const currentTime = now.getTime(); + if (reportedAt > currentTime) return false; + return currentTime - reportedAt <= windowMs; } From 0093e2b4e4fa910b02a9ff871456156b0a249437 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 10:07:42 +0530 Subject: [PATCH 3/3] fix(core): use kind for orchestrator skip; honest report-surfacing copy Address Copilot review feedback on PR #117: - lifecycle-manager: replace string-matching guards (`metadata["role"]`, `session.id.endsWith("-orchestrator")`) with the authoritative `lifecycle.session.kind !== "orchestrator"` check. The previous guard missed numbered orchestrator IDs like `${prefix}-orchestrator-1`, which could let a fresh agent report incorrectly override an orchestrator session's status. - orchestrator-prompt: reword the "Explicit Agent Reports" section to stop promising automatic surfacing in status/dashboard. CLI/UI display work is not part of this PR; the prompt now describes reports as a hint to lifecycle inference and explicitly notes SCM/runtime truth still wins. Co-Authored-By: Claude --- packages/core/src/lifecycle-manager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 6c1a5d602..3d567e41b 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -791,13 +791,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // Fresh agent reports outrank weak inference (idle-beyond-threshold / // default-to-working) but runtime death, activity waiting_input, and SCM // ground truth already short-circuited above. Orchestrator sessions and - // terminal states are skipped intentionally. + // terminal states are skipped intentionally — `lifecycle.session.kind` is + // the authoritative source (string-matching role/id suffixes misses + // numbered orchestrator IDs like `${prefix}-orchestrator-1`). const agentReport = readAgentReport(session.metadata); if ( agentReport && isAgentReportFresh(agentReport) && - session.metadata["role"] !== "orchestrator" && - !session.id.endsWith("-orchestrator") && + lifecycle.session.kind !== "orchestrator" && lifecycle.session.state !== "terminated" && lifecycle.session.state !== "done" ) {