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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.

## [Unreleased]

## [2026-07-14] — v0.13.1 · Reliable agent wake boundaries

### Changed

- **Activity and notifications no longer imply agent work.** Labels, ordinary status changes, non-escalating priority changes, stall and SLA markers, coach output, and other system activity remain visible without invoking an LLM.
- **Waiting agents resume through an explicit, engine-aware boundary.** Actionable human replies, direct mentions, explicit restarts, reassignment, and true priority escalation can wake work; RUNS agents receive a fresh provider turn while webhook and completions agents reactivate directly.

### Fixed

- **Agent output cannot wake its author.** Terminal and stalled replies, blocking markers, and automated comments no longer create replacement runs, while nudges produce one delivery instead of two.
- **Every terminal run leaves exactly one durable issue reply.** Completion, stall, and abandonment comments are claimed atomically so concurrent pollers cannot duplicate them.

## [2026-07-14] — v0.13.0 · Reliable lifecycle operations

### Added
Expand Down
36 changes: 36 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12728,3 +12728,39 @@ cover card content while scrolling. The repository-wide browser run passed 36
of 38 tests in parallel; the two failures (shared chat state and a composer
locator exposed by the accessibility change) both passed independently after
the composer name was corrected.

---

## 2026-07-14 — Agent wake-boundary and terminal-comment audit

Traced AXI-104 from production activity, delivery, and run records after a
stalled agent reply immediately opened a replacement run. The audit found that
agent-authored terminal comments were being judged by mutable payload fields
instead of authoritative actor identity; human status edits could directly
resume the assigned agent; all priority changes woke watchers; nudges emitted
two independently deliverable events; blocking and coach output could appear
operator-authored; and WAITING runs could be revived by incidental touches.

Centralized wake policy now separates notification-only activity from
actionable work. Human BODY replies, safe agent-to-agent mentions, assignment,
explicit restart, and upward escalation into HIGH or URGENT are wakeable;
self-authored, actor-less, status, label, downward priority, stall, SLA, coach,
and blocking events remain durable activity without invoking an agent. Human
status changes no longer impersonate agent progress. The nudge marker and
AGENT_RUN_BLOCKED remain observable but do not create duplicate or bounce-back
deliveries.

WAITING resume is now explicit and engine-aware. RUNS agents stay parked until
the dispatcher validates the canonical trigger and starts a fresh provider
turn containing the operator input; webhook and completions agents reactivate
for the actionable delivery. Completion, stall, and abandonment now use one
atomic terminal claim that creates exactly one agent comment even under
concurrent pollers and records its durable comment ID on the run.

Verification: lint passed with existing repository warnings; typecheck passed;
the full Vitest suite passed 1,236 tests with 1 intentional live-connector skip;
the post-audit engine compatibility suites passed 28 tests; a production Next
build passed; and all 38 Playwright journeys passed serially. The canonical
gate first encountered the known cross-suite stale-work redispatch race; that
9-test file passed immediately in isolation and the full suite then passed
cleanly.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
146 changes: 35 additions & 111 deletions src/server/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
ALERTABLE_EVENT_KINDS,
scheduleEventNotificationFanout,
} from "@/server/services/notifications";
import {
isActionableAssignedComment,
isActionableExplicitMention,
isActionablePriorityEscalation,
mentionedAgentIdsFromWakePayload,
} from "@/server/services/agent-wake-policy";

/**
* Synthetic slug + url used to route agent-bound dispatches through the
Expand Down Expand Up @@ -43,59 +49,7 @@ export function agentDispatchUrlFor(agentId: string): string {
return `${AGENT_DISPATCH_WEBHOOK_URL_PREFIX}${agentId}`;
}

function mentionedAgentIdsFromPayload(payloadIn: unknown): string[] {
const payload = payloadIn as
| {
agentRequests?: Array<{ agentId?: unknown }>;
mentions?:
| {
agentIds?: unknown[];
agents?: Array<{ agentId?: unknown }>;
}
| Array<{ agentId?: unknown }>;
}
| undefined;
const out = new Set<string>();
if (Array.isArray(payload?.agentRequests)) {
for (const request of payload.agentRequests) {
if (typeof request.agentId === "string" && request.agentId.length > 0) {
out.add(request.agentId);
}
}
}
const mentionsRaw = payload?.mentions;
if (Array.isArray(mentionsRaw)) {
for (const mention of mentionsRaw) {
if (typeof mention.agentId === "string" && mention.agentId.length > 0) {
out.add(mention.agentId);
}
}
return [...out];
}
if (mentionsRaw && typeof mentionsRaw === "object") {
if (Array.isArray(mentionsRaw.agentIds)) {
for (const id of mentionsRaw.agentIds) {
if (typeof id === "string" && id.length > 0) out.add(id);
}
}
if (Array.isArray(mentionsRaw.agents)) {
for (const mention of mentionsRaw.agents) {
if (typeof mention.agentId === "string" && mention.agentId.length > 0) {
out.add(mention.agentId);
}
}
}
}
return [...out];
}

const AGENT_WATCHER_FANOUT_EVENT_KINDS = new Set<EventKind>([
EventKind.COMMENT_CREATED,
EventKind.ISSUE_PRIORITY_CHANGED,
EventKind.ISSUE_STALLED,
EventKind.ISSUE_SLA_BREACH,
EventKind.ISSUE_NUDGED,
]);
const AGENT_WATCHER_FANOUT_EVENT_KINDS = new Set<EventKind>([EventKind.COMMENT_CREATED]);

const ENGAGEMENT_MODE_LABEL: Record<string, string> = {
EXECUTE: "EXECUTE",
Expand Down Expand Up @@ -455,11 +409,7 @@ async function upsertAgentDispatchWebhook(
EventKind.ISSUE_QUEUED,
EventKind.COMMENT_CREATED,
EventKind.ISSUE_PRIORITY_CHANGED,
EventKind.ISSUE_STALLED,
EventKind.ISSUE_SLA_BREACH,
EventKind.ISSUE_NUDGED,
EventKind.CHAT_MESSAGE_POSTED,
EventKind.AGENT_RUN_BLOCKED,
],
active: true,
},
Expand Down Expand Up @@ -911,8 +861,7 @@ export async function recordChange(
// to the assignee via a per-agent shim so the delivery carries its
// own target id (doesn't re-resolve the issue's assignee later).
if (params.eventKind === EventKind.ISSUE_PRIORITY_CHANGED && params.subjectType === "issue") {
const to = (params.payload as { to?: string } | undefined)?.to;
if (to === "HIGH" || to === "URGENT") {
if (isActionablePriorityEscalation(params.payload)) {
const issue = await tx.issue.findUnique({
where: { id: params.subjectId },
select: {
Expand Down Expand Up @@ -976,7 +925,7 @@ export async function recordChange(
(params.subjectType === "issue" || params.subjectType === "execution-step");
if (isCommentMentionEvent) {
// Normalize to a flat list of agent ids regardless of incoming shape.
const mentionedAgentIds = mentionedAgentIdsFromPayload(params.payload);
const mentionedAgentIds = [...mentionedAgentIdsFromWakePayload(params.payload)];
if (mentionedAgentIds.length) {
// Load mentioned agents within workspace scope. We canonicalize
// every mentioned active agent into the inbox so the operator's
Expand Down Expand Up @@ -1006,6 +955,16 @@ export async function recordChange(
},
});
for (const a of agents) {
if (
!isActionableExplicitMention({
actorId: params.actorId,
actorAgentId: params.actorAgentId ?? null,
targetAgentId: a.id,
payload: params.payload,
})
) {
continue;
}
resolvedAgentIds.push(a.id);
if (a.webhookUrl && !isRunsDrivenAgent(a)) {
const wid = await upsertAgentDispatchWebhook(
Expand Down Expand Up @@ -1053,47 +1012,10 @@ export async function recordChange(
}
}

// (d2) AGENT_RUN_BLOCKED — the agent itself just flipped a run to
// WAITING via `runs.setWaiting({ blocking: true })`. Route the
// event back to the agent's own webhook so the runtime sees its
// block confirmed (useful for receipts / acks). Subject is the
// agent-run row; we resolve the owning agent from the payload's
// `agentId`. Also surfaces the event in the standard agent
// activity feed via the normal ActivityEvent write that already
// happened above.
if (params.eventKind === EventKind.AGENT_RUN_BLOCKED && params.subjectType === "agent-run") {
const payload = params.payload as { agentId?: string } | undefined;
const blockedAgentId =
typeof payload?.agentId === "string" && payload.agentId.length > 0 ? payload.agentId : null;
if (blockedAgentId) {
const agent = await tx.agent.findFirst({
where: {
workspaceId: params.workspaceId,
id: blockedAgentId,
archivedAt: null,
},
select: { id: true, webhookUrl: true },
});
if (agent) {
resolvedAgentIds.push(agent.id);
if (agent.webhookUrl) {
const wid = await upsertAgentDispatchWebhook(
tx,
params.workspaceId,
agentDispatchUrlFor(agent.id),
);
agentWebhookIds.push(wid);
}
}
}
}

// (e) Watchers — for actionable issue-subject events, fan out to every
// subscribed agent watcher whose webhook is configured. Skip
// the actor's own row to avoid self-paging. Human watchers
// don't need a synthetic webhook — they're served by the
// inbox / notification surfaces. Agent watchers are routed
// through the same per-agent shim used for comment @mentions.
// (e) Watchers — human BODY comments can wake the currently assigned
// agent; explicit @mentions can wake another watched agent. Agent
// output requires an explicit mention and can never wake its author.
// Human watchers remain served by inbox / notification surfaces.
//
// Deliberately exclude low-signal lifecycle/status churn from the
// durable agent inbox. A watched issue transitioning status or a
Expand Down Expand Up @@ -1124,7 +1046,7 @@ export async function recordChange(
!isCommentEdit &&
!isRollingStatusComment;
if (isAgentWatcherFanoutEvent) {
const mentionedAgentIds = new Set(mentionedAgentIdsFromPayload(params.payload));
const mentionedAgentIds = mentionedAgentIdsFromWakePayload(params.payload);
const currentIssue =
params.eventKind === EventKind.COMMENT_CREATED
? await tx.issue.findUnique({
Expand Down Expand Up @@ -1163,6 +1085,17 @@ export async function recordChange(
});
for (const w of watchers) {
if (!w.agent) continue;
if (
params.eventKind === EventKind.COMMENT_CREATED &&
!isActionableAssignedComment({
actorId: params.actorId,
actorAgentId: params.actorAgentId ?? null,
targetAgentId: w.agent.id,
payload: params.payload,
})
) {
continue;
}
// Body comments should wake the assigned agent and explicit @mentions.
// Former assignees can remain sticky watchers; that stale watcher row
// should not become fresh canonical work after reassignment.
Expand All @@ -1176,15 +1109,6 @@ export async function recordChange(
if (params.eventKind !== EventKind.COMMENT_CREATED && !w.wakeOnActivity) {
continue;
}
// Don't fan out the actor's own action back to themselves —
// when an agent comments and is also a watcher, the COMMENT_CREATED
// already routes via the assigned-agent + mention shims. Watcher
// fan-out is for OTHER stakeholders.
// Note: actorId is a User id, agentId is an Agent id; equality
// is impossible. We instead detect agent-as-actor via the
// payload's `agentId` field when present.
const payloadAgentId = (params.payload as { agentId?: string } | undefined)?.agentId;
if (payloadAgentId && payloadAgentId === w.agent.id) continue;
resolvedAgentIds.push(w.agent.id);
if (w.agent.webhookUrl && !isRunsDrivenAgent(w.agent)) {
const wid = await upsertAgentDispatchWebhook(
Expand Down
57 changes: 57 additions & 0 deletions src/server/routers/__tests__/issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,63 @@ describe("issueRouter — list filtering", () => {
});
});

describe("issueRouter — agent wake boundaries", () => {
it("records a human status transition without resuming the assigned agent", async () => {
const { caller, fixture } = await setup();
const prisma = getPrisma();
const issue = await createIssue(fixture, { title: "Metadata is not a prompt" });
const currentIssue = await prisma.issue.findUniqueOrThrow({
where: { id: issue.id },
select: { statusId: true },
});
const agent = await prisma.agent.create({
data: {
workspaceId: fixture.workspace.id,
name: "Status worker",
profileKey: `status-worker-${Date.now()}`,
},
});
await prisma.issue.update({
where: { id: issue.id },
data: { assignedAgentId: agent.id },
});
const pausedAt = new Date(Date.now() - 60_000);
const run = await prisma.agentRun.create({
data: {
workspaceId: fixture.workspace.id,
issueId: issue.id,
agentId: agent.id,
status: AgentRunStatus.WAITING,
lastEventAt: pausedAt,
currentStep: "waiting for the operator",
},
});
const nextStatus = await prisma.status.findFirstOrThrow({
where: {
workspaceId: fixture.workspace.id,
id: { not: currentIssue.statusId },
category: { in: ["TODO", "IN_PROGRESS"] },
},
});

await caller.update({ id: issue.id, statusId: nextStatus.id });

const after = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } });
expect(after.status).toBe(AgentRunStatus.WAITING);
expect(after.lastEventAt).toEqual(pausedAt);
expect(after.currentStep).toBe("waiting for the operator");
expect(
await prisma.activityEvent.count({
where: {
workspaceId: fixture.workspace.id,
subjectId: issue.id,
kind: "ISSUE_STATUS_CHANGED",
},
}),
).toBe(1);
});
});

describe("issueRouter — archive integrity", () => {
it("archives reversibly, closes live work, clears dispatch state, and audits both directions", async () => {
const { caller, fixture } = await setup();
Expand Down
2 changes: 2 additions & 0 deletions src/server/routers/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@ export const issueRouter = router({
// Touch the run on transition by the assigned agent so the
// status-change is reflected in the timeline as a STEP event.
before.assignedAgentId &&
ctx.apiKey?.linkedAgentId === before.assignedAgentId &&
patch.statusId &&
patch.statusId !== before.statusId
) {
Expand All @@ -1449,6 +1450,7 @@ export const issueRouter = router({
payload: { from: before.statusId, to: patch.statusId, category: nextCategory },
actorId: ctx.session.user.id,
actorAgentId: ctx.apiKey?.linkedAgentId ?? null,
resumeWaiting: true,
});
}

Expand Down
Loading
Loading