Merge pull request #117 from harshitsinghbhandari/feat/stage3-explicit-agent-reporting
feat(core): Stage 3 — explicit agent reporting and workflow coordination
This commit is contained in:
commit
f2b74a8fc8
|
|
@ -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<void> {
|
||||
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 <text>", "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("<state>", `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, ...)`)
|
||||
.option("-s, --session <id>", "Session ID (defaults to AO_SESSION_ID)")
|
||||
.option("--note <text>", "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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
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["session"]>,
|
||||
): 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("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("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");
|
||||
});
|
||||
|
||||
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 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(false);
|
||||
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(false);
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Agent Report — explicit workflow transitions declared by the worker agent.
|
||||
*
|
||||
* Stage 3 of the state-machine redesign. Agents run `ao acknowledge` and
|
||||
* `ao report <state>` 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.
|
||||
*
|
||||
* 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<string, AgentReportedState> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
/** 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" };
|
||||
}
|
||||
// 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") {
|
||||
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<string, string> = {
|
||||
[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<string, string> | 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).
|
||||
*
|
||||
* 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(),
|
||||
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
|
||||
): boolean {
|
||||
const reportedAt = Date.parse(report.timestamp);
|
||||
if (Number.isNaN(reportedAt)) return false;
|
||||
const currentTime = now.getTime();
|
||||
if (reportedAt > currentTime) return false;
|
||||
return currentTime - reportedAt <= windowMs;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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 — `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) &&
|
||||
lifecycle.session.kind !== "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);
|
||||
|
|
|
|||
|
|
@ -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 "<text>"\` 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.).`;
|
||||
|
|
|
|||
|
|
@ -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 <state>` (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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue