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
20 changes: 20 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-27 — Actionable Delivery recovery requests

- Replaced the dead-end Dismiss/Open Issue treatment for quiet or stale
Delivery sessions with server-derived **Still working**, **Resume session**,
and **Abandon session** actions in both Command Center and issue detail.
- Added **Respond in issue** as the safe fallback for generic asks that have no
registered reply transport. The issue opens with a prepared comment while
preserving the operator's ability to edit before sending.
- Renamed the destructive-looking generic dismissal to **Dismiss card** and
made its scope explicit: it clears the card only and does not resolve the
underlying Delivery condition.
- Stored operator confirmation independently from lifecycle heartbeats. A
confirmation prevents immediate alert recreation without fabricating fresh
MCP or runtime evidence.
- Added schema, service, router, presentation, issue-local UI, unit/integration,
and focused Playwright coverage. The full local quality gate passed with
1,523 tests passed and one skipped; the focused Chromium scenario also
passed against the isolated local service stack.

## 2026-07-25 — v0.30.0 release preparation

Prepared the serialized minor release after implementation PRs #80, #89, and
Expand Down Expand Up @@ -118,6 +137,7 @@ with an explicit, keyboard-accessible Show more / Show fewer affordance. Asks
no longer expands without limit, and Run recovery no longer hides additional
cards in a clipped nested scroll area. Responsive Playwright coverage now
guards both the dashboard rail fill and the attention overflow contract.

## 2026-07-20 — AXI-138 faster and safer local iteration

Scoped Turbopack to the Forge repository root so unrelated parent lockfiles no
Expand Down
18 changes: 18 additions & 0 deletions docs/engineering/work-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ Claimed → In progress → PR open → In review → Ready to merge → Merged
ownership lease. A stale session remains a lease and raises a shared action
request; it must be resumed or abandoned before replacement work starts.

Delivery recovery requests expose lifecycle-aware operator actions wherever
the request appears:

- **Still working** confirms that a quiet MCP-owned lease should remain
reserved.
- **Resume session** returns a stale lease to in progress.
- **Abandon session** explicitly releases ownership.
- **Respond in issue** opens the issue with a prepared comment when the request
needs human context instead of a structured transition.
- **Dismiss card** removes only the current Attention Queue card. It does not
change Delivery ownership or claim that the underlying condition was fixed.

Operator confirmation is stored separately from the connection heartbeat. It
suppresses repeated recovery prompts for the normal stale interval without
claiming that MCP, a runtime, or any other client emitted fresh lifecycle
evidence. The owning connection must still heartbeat, complete, hand off, or
abandon the session explicitly.

For direct MCP work, clients open an explicit run before implementation.
Opening an `EXECUTE` run moves a Backlog or Todo issue to the workspace's
configured In Progress status; successfully completing it moves the issue to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "WorkSession"
ADD COLUMN "operatorConfirmedAt" TIMESTAMP(3);
54 changes: 29 additions & 25 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2719,34 +2719,38 @@ model AgentConnection {
/// branch for the same issue. Terminal rows remain as the issue's delivery
/// history; a partial unique index in the migration permits one active row.
model WorkSession {
id String @id @default(cuid())
workspaceId String
issueId String
ownerUserId String?
ownerAgentId String?
id String @id @default(cuid())
workspaceId String
issueId String
ownerUserId String?
ownerAgentId String?
/// Concrete endpoint that owns execution/heartbeat authority. The logical
/// owner remains ownerAgentId; nullable preserves legacy/manual sessions.
ownerConnectionId String?
source WorkSessionSource
status WorkSessionStatus @default(CLAIMED)
repoFullName String
branch String
baseBranch String @default("main")
ownerConnectionId String?
source WorkSessionSource
status WorkSessionStatus @default(CLAIMED)
repoFullName String
branch String
baseBranch String @default("main")
/// Local path is diagnostic only; it is never used as an execution target.
worktreePath String?
headSha String?
pullRequestId String?
releasedVersion String?
deployedSha String?
lastHeartbeatAt DateTime @default(now())
staleAt DateTime?
mergedAt DateTime?
releasedAt DateTime?
deployedAt DateTime?
verifiedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
worktreePath String?
headSha String?
pullRequestId String?
releasedVersion String?
deployedSha String?
lastHeartbeatAt DateTime @default(now())
/// Human confirmation that the delivery lease should remain reserved.
/// Kept separate from lifecycle heartbeats so operator intent never
/// fabricates MCP or runtime evidence.
operatorConfirmedAt DateTime?
staleAt DateTime?
mergedAt DateTime?
releasedAt DateTime?
deployedAt DateTime?
verifiedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
Expand Down
44 changes: 43 additions & 1 deletion src/app/(app)/w/[slug]/command-center/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ import { RunApprovalCard } from "@/components/agents/run-approval-card";
import { ActionRequestCard } from "@/components/action-requests/action-request-card";
import {
isDeliveryConflictDecision,
isWorkSessionAttentionDecision,
safeAttentionActions,
type AttentionAction,
type AttentionRequestPresentation,
} from "@/components/action-requests/attention-request-model";
import { readDraft, saveDraft } from "@/components/ui/modal/draft";
import { MarkdownWithAttachments } from "@/components/markdown/attachment-renderer";
import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline";
import { IssueReference } from "@/components/issue-reference";
Expand Down Expand Up @@ -649,6 +651,7 @@ function ActionRequestDecisionCard({
const presentation = request.presentation ?? null;
const replyTarget = presentation?.replyTarget ?? null;
const isDeliveryConflict = presentation?.protocol === "DELIVERY_CONNECTION_CONFLICT";
const isWorkSessionRecovery = presentation?.protocol === "WORK_SESSION_RECOVERY";
const issueHref = request.issue
? `/w/${request.issue.workspace.slug || slug}/i/${request.issue.workspace.key}-${request.issue.number}`
: null;
Expand Down Expand Up @@ -698,9 +701,29 @@ function ActionRequestDecisionCard({
onSuccess: () => toast.success("Delivery decision applied."),
onSettled: settle,
});
const resolveWorkSessionAttention = trpc.actionRequest.resolveWorkSessionAttention.useMutation({
onMutate: () => onResolved(),
onError: (e) => {
toast.error(e.message);
void utils.commandCenter.summary.invalidate();
},
onSuccess: (result) =>
toast.success(
result.decision === "ABANDON_SESSION"
? "Delivery session abandoned."
: result.decision === "RESUME_SESSION"
? "Delivery session resumed."
: "Operator confirmation recorded. MCP evidence remains unchanged.",
),
onSettled: settle,
});

const pending =
accept.isPending || decline.isPending || dismiss.isPending || resolveDeliveryConflict.isPending;
accept.isPending ||
decline.isPending ||
dismiss.isPending ||
resolveDeliveryConflict.isPending ||
resolveWorkSessionAttention.isPending;

const runAction = async (action: AttentionAction) => {
if (!action.enabled) return;
Expand All @@ -720,6 +743,25 @@ function ActionRequestDecisionCard({
return;
}

if (action.id === "RESPOND_IN_ISSUE") {
if (!issueHref || !request.issue) return;
const draftKey = `comment:${request.issue.id}`;
const existing = readDraft<string>(draftKey);
const context = `Regarding "${request.title}":\n\n`;
if (!existing?.trim()) saveDraft(draftKey, context);
window.location.assign(`${issueHref}#issue-comments-heading`);
return;
}

if (isWorkSessionRecovery) {
if (!isWorkSessionAttentionDecision(action.id)) {
toast.error("This Delivery action is not supported. Open the issue to continue safely.");
return;
}
resolveWorkSessionAttention.mutate({ id: request.id, decision: action.id });
return;
}

if (isDeliveryConflict) {
if (!isDeliveryConflictDecision(action.id)) {
toast.error("This delivery action is not supported. Open the issue to continue safely.");
Expand Down
24 changes: 24 additions & 0 deletions src/components/action-requests/attention-request-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,33 @@ export const DELIVERY_CONFLICT_DECISIONS = [

export type DeliveryConflictDecision = (typeof DELIVERY_CONFLICT_DECISIONS)[number];

export const WORK_SESSION_ATTENTION_DECISIONS = [
"CONFIRM_ACTIVE",
"RESUME_SESSION",
"ABANDON_SESSION",
] as const;

export type WorkSessionAttentionDecision = (typeof WORK_SESSION_ATTENTION_DECISIONS)[number];

const GENERIC_ATTENTION_ACTIONS = [
"ACCEPT",
"DECLINE",
"DISMISS",
"RESPOND",
"RESPOND_IN_ISSUE",
"OPEN_ISSUE",
] as const;

export function isDeliveryConflictDecision(value: string): value is DeliveryConflictDecision {
return DELIVERY_CONFLICT_DECISIONS.some((decision) => decision === value);
}

export function isWorkSessionAttentionDecision(
value: string,
): value is WorkSessionAttentionDecision {
return WORK_SESSION_ATTENTION_DECISIONS.some((decision) => decision === value);
}

/**
* The server owns labels and availability, but the browser still keeps a
* narrow execution allowlist. Unknown descriptors remain visible through the
Expand All @@ -58,6 +73,15 @@ export function safeAttentionActions(
action.id === "DISMISS",
);
}
if (presentation.protocol === "WORK_SESSION_RECOVERY") {
return presentation.actions.filter(
(action) =>
isWorkSessionAttentionDecision(action.id) ||
action.id === "RESPOND_IN_ISSUE" ||
action.id === "OPEN_ISSUE" ||
action.id === "DISMISS",
);
}
return presentation.actions.filter((action) =>
GENERIC_ATTENTION_ACTIONS.some((allowed) => allowed === action.id),
);
Expand Down
Loading
Loading