Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 322 additions & 32 deletions src/agent-engine.ts

Large diffs are not rendered by default.

92 changes: 70 additions & 22 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10801,43 +10801,90 @@ export function createServer(opts?: CreateServerOptions): McpServer {
// reads the last screen scan only -- no I/O on the caller's path -- and
// returns null when there is no fresh evidence, which degrades to the
// registry record with honest `registry` provenance.
const screenObservationForRecord = (
type ScreenObservationRow = {
surface_id: string;
surface_uuid?: string | null;
parsed_status?: string | null;
control_state?: string | null;
cli?: string | null;
read_error?: unknown;
};
// Same binding rule list_agents uses: a UUID pair, or a surface_id match
// ONLY when neither side has a UUID and this observer owns the seat.
// A looser match would let an unrelated pane's screen decide an agent's
// state, which is a worse lie than the stale record it replaces.
const rowBindsToRecord = (
agent: AgentRecord,
row: ScreenObservationRow,
): boolean => {
const uuidKey = (value: string | null | undefined): string | null =>
value?.trim().toLowerCase() || null;
const agentUuid = uuidKey(agent.surface_uuid);
const surfaceUuid = uuidKey(row.surface_uuid);
return agentUuid && surfaceUuid
? agentUuid === surfaceUuid
: Boolean(
!agentUuid &&
!surfaceUuid &&
agent.surface_observer_id &&
agent.surface_observer_id === registry.getObserverId() &&
row.surface_id === agent.surface_id,
);
};
const observationFromRow = (
row: ScreenObservationRow | null | undefined,
): {
status: string | null;
agent_type: string | null;
control_state: string | null;
} | null => {
const cached = discovery.cachedScan();
if (!cached) return null;
const uuidKey = (value: string | null | undefined): string | null =>
value?.trim().toLowerCase() || null;
const agentUuid = uuidKey(agent.surface_uuid);
// Same binding rule list_agents uses: a UUID pair, or a surface_id match
// ONLY when neither side has a UUID and this observer owns the seat.
// A looser match would let an unrelated pane's screen decide an agent's
// state, which is a worse lie than the stale record it replaces.
const row = cached.rows.find((surface) => {
const surfaceUuid = uuidKey(surface.surface_uuid);
return agentUuid && surfaceUuid
? agentUuid === surfaceUuid
: Boolean(
!agentUuid &&
!surfaceUuid &&
agent.surface_observer_id &&
agent.surface_observer_id === registry.getObserverId() &&
surface.surface_id === agent.surface_id,
);
});
if (!row || row.read_error) return null;
return {
status: row.parsed_status ?? null,
agent_type: row.cli === "kiro" ? "unknown" : (row.cli ?? null),
control_state: row.control_state ?? null,
};
};
const screenObservationForRecord = (
agent: AgentRecord,
): {
status: string | null;
agent_type: string | null;
control_state: string | null;
} | null => {
const cached = discovery.cachedScan();
if (!cached) return null;
return observationFromRow(
cached.rows.find((row) => rowBindsToRecord(agent, row)),
);
};
liveAgentStateProbe.current = (agent) =>
resolveLiveAgentState(agent, screenObservationForRecord(agent));
/**
* AIDEV-NOTE (F1b round 2): the FORCING probe. `cachedScan()` is
* deliberately evidence-free once it is 2000ms old, and nothing on the
* `wait_for` path refreshes it -- so a wait that only read the cache
* degraded straight back to the poisoned record. This reads ONE surface on
* demand (`scanTarget`, not a fleet `scan`), applies the same binding rule,
* and returns null on a failed read or an unbound surface: no evidence,
* which leaves the record unchallenged rather than inventing a state.
*/
const freshLiveAgentStateProbe = async (
agent: AgentRecord,
): Promise<LiveAgentState | null> => {
try {
const row = await discovery.scanTarget({
surface_id: agent.surface_id,
surface_uuid: agent.surface_uuid ?? null,
});
if (!row || !rowBindsToRecord(agent, row)) return null;
const observation = observationFromRow(row);
return observation ? resolveLiveAgentState(agent, observation) : null;
} catch {
// A failed or racing scan is not evidence of anything.
return null;
}
};
const awaitLifecycleStart = async (): Promise<void> => {
if (context.lifecycleStartPromise) {
await context.lifecycleStartPromise;
Expand Down Expand Up @@ -11170,6 +11217,7 @@ export function createServer(opts?: CreateServerOptions): McpServer {
// F1: closure, harvestability and the health report all resolve state
// through the same live probe the caller/delivery paths use.
engine.setLiveStateResolver(liveAgentStateProbe.current);
engine.setFreshLiveStateProbe(freshLiveAgentStateProbe);

server.tool(
"arm_watch",
Expand Down
12 changes: 11 additions & 1 deletion src/watch-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ export interface WatchAgentObservation {
exists: boolean;
state: string | null;
source: string;
/**
* AIDEV-NOTE (F1b, #472): what the observer actually saw, in the observer's
* own words ("registry hit, screen unparseable"). The arm refusal quotes it
* instead of asserting the agent "does not exist" -- a claim the observer
* cannot make from a failed screen read, and one that was demonstrably false
* for agents `send_to` was delivering to in the same second.
*/
detail?: string;
}

export interface WatchRegistryOptions {
Expand Down Expand Up @@ -490,7 +498,9 @@ export async function armWatch(
throw new WatchArmError(
"watch_target_missing",
target,
`Watch target agent does not exist: ${target}`,
`Watch target agent is not observable: ${target} (${
agentObservation?.detail ?? "no observation was returned"
})`,
);
}
const source: WatchObservedSource =
Expand Down
26 changes: 25 additions & 1 deletion tests/coordination-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,37 @@ describe("P11 closure state (Constraint 3: no bare boolean at default detail)",
expect(deadlocked).not.toBe(working);
});

it("F1b: done WITHOUT done evidence => pending, never the artifact_missing alarm", () => {
// #408 flips live records to `done` on its own. `artifact_missing` means
// "route a reviewer NOW", so a record flip must not be able to fire it.
expect(
resolveClosureState({
contractIssued: true,
state: "done",
closureArtifactVerified: false,
doneEvidence: false,
}),
).toBe("pending");
});

it("F1b: a verified artifact stands on its own, evidence channel or not", () => {
expect(
resolveClosureState({
contractIssued: true,
state: "done",
closureArtifactVerified: true,
doneEvidence: false,
}),
).toBe("verified");
});

it("no contract issued => not_applicable, never a falsey negative", () => {
expect(
resolveClosureState({
contractIssued: false,
doneEvidence: true,
state: "done",
closureArtifactVerified: null,
doneEvidence: true,
}),
).toBe("not_applicable");
});
Expand Down
38 changes: 34 additions & 4 deletions tests/f1-live-state-truth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,13 @@ describe("F1 — live state, not the stale registry record", () => {
agent_id: "cmuxlayerCodex-finished",
surface_id: client.idleSurface,
state: "done",
// T1b (#488): the record alone is no longer enough to claim a deadlock
// -- #408 writes `done` on live agents without anything observing one.
// This agent's done WAS observed, so the signal must survive.
task_done_detected_at: "2026-08-18T13:41:00.000Z",
report_path: join(TEST_DIR, "reports", "missing.md"),
done_marker: "### @cmuxlayerCodex-finished DONE",
// F1b round 3: this worker EARNED its done -- a done signal was
// detected on its screen. Without that, the fixture is
// indistinguishable from a #408 record flip, and the sibling test
// below is what that shape must produce.
task_done_detected_at: "2026-08-19T10:05:00.000Z",
} as Partial<AgentRecord> as any),
);

Expand All @@ -479,4 +480,33 @@ describe("F1 — live state, not the stale registry record", () => {
// overturn a recorded done: the deadlock signal has to survive.
expect(row.closure).toBe("artifact_missing");
});

it("F1b: a fresh agent at a ready prompt whose record flipped done reads pending", async () => {
client.screens["surface:idle"] = [
"gpt-5.5 xhigh · 99% left · ~/Gits/cmuxlayer",
"codex>",
].join("\n");
registerAgent(
server,
makeAgent({
agent_id: "cmuxlayerCodex-flipped",
surface_id: client.idleSurface,
state: "done",
report_path: join(TEST_DIR, "reports", "missing.md"),
done_marker: "### @cmuxlayerCodex-flipped DONE",
} as Partial<AgentRecord> as any),
);

const result = await callTool(server, "list_agents", { detail: "full" });
const parsed = parseResult(result);
const row = parsed.agents.find(
(agent: any) => agent.agent_id === "cmuxlayerCodex-flipped",
);
expect(row, JSON.stringify(parsed)).toBeTruthy();
// Same screen, same missing report — and NOTHING ever observed this task
// ending. The row's own `state` says the agent is at a live prompt, so the
// closure beside it may not say the work is over and unaccounted for.
expect(row.state.value).toBe("ready");
expect(row.closure).toBe("pending");
});
});
Loading
Loading