Skip to content

Commit e6d9b87

Browse files
cursor[bot]cursoragentarul28
authored
Fix Linear review bypass and headless usage tracking start (#374)
* Fix Linear supervisor review bypass via early approve/reject Guard resolveRunAction approve/reject so they only succeed when the run is actively awaiting_human_review on a request_human_review step. Previously an agent or CLI call could pre-set reviewState=approved while the run was still in_progress, causing advanceRun to auto-skip the gate. Add regression test for early approve rejection. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * Start usage tracking polling in ade-cli bootstrap createAdeRuntime constructed usageTrackingService but never called start(), so headless/daemon runs had empty usage snapshots and percent-based budget caps never blocked dispatch. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * Tighten Linear review gate and usage startup * Fix ADE RPC action audit test --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> Co-authored-by: Arul Sharma <31745423+arul28@users.noreply.github.com>
1 parent c2f28f8 commit e6d9b87

6 files changed

Lines changed: 90 additions & 18 deletions

File tree

apps/ade-cli/src/adeRpcServer.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3042,19 +3042,16 @@ describe("adeRpcServer", () => {
30423042
expect(payload.rebaseStatus).toBe("idle");
30433043
});
30443044

3045-
it("records succeeded audit metadata for read-only tools", async () => {
3045+
it("does not record operation metadata for read-only action calls", async () => {
30463046
const { runtime, operationStart, operationFinish } = createRuntime();
30473047
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
30483048

30493049
await initialize(handler);
30503050
const response = await callTool(handler, "list_lanes", {});
30513051

30523052
expect(response.isError).toBeUndefined();
3053-
expect(operationStart).toHaveBeenCalledTimes(1);
3054-
expect(operationFinish).toHaveBeenCalledTimes(1);
3055-
const finishArgs = operationFinish.mock.calls[0]?.[0] ?? {};
3056-
expect(finishArgs.status).toBe("succeeded");
3057-
expect(finishArgs.metadataPatch?.resultStatus).toBe("success");
3053+
expect(operationStart).not.toHaveBeenCalled();
3054+
expect(operationFinish).not.toHaveBeenCalled();
30583055
});
30593056

30603057
// ---------- Rate limit tests ----------

apps/ade-cli/src/bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,7 @@ export async function createAdeRuntime(args: {
12021202
};
12031203
automationService.bindAdeActionRegistry(adeActionLookup);
12041204

1205+
usageTrackingService.start();
12051206
runtimeCreated = true;
12061207
return runtime;
12071208
} finally {

apps/desktop/src/main/services/cto/linearDispatcherService.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,17 +2402,21 @@ export function createLinearDispatcherService(args: {
24022402
targetStatus: currentTargetStatus,
24032403
});
24042404
} else if (action === "approve") {
2405-
if (currentStepRow && currentStep?.type === "request_human_review") {
2406-
updateStep(currentStepRow.id, {
2407-
status: "completed",
2408-
completedAt: nowIso(),
2409-
payload: {
2410-
reviewState: "approved",
2411-
reviewerIdentityKey: reviewContext?.reviewerIdentityKey ?? null,
2412-
note: note ?? null,
2413-
},
2414-
});
2405+
if (run.status !== "awaiting_human_review") {
2406+
throw new Error("This workflow run is not awaiting supervisor review.");
2407+
}
2408+
if (!currentStep || currentStep.type !== "request_human_review" || !currentStepRow) {
2409+
throw new Error("This workflow run is not on a human review step.");
24152410
}
2411+
updateStep(currentStepRow.id, {
2412+
status: "completed",
2413+
completedAt: nowIso(),
2414+
payload: {
2415+
reviewState: "approved",
2416+
reviewerIdentityKey: reviewContext?.reviewerIdentityKey ?? null,
2417+
note: note ?? null,
2418+
},
2419+
});
24162420
updateRun(run.id, {
24172421
reviewState: "approved",
24182422
latestReviewNote: note ?? null,
@@ -2429,6 +2433,12 @@ export function createLinearDispatcherService(args: {
24292433
});
24302434
}
24312435
} else if (action === "reject") {
2436+
if (run.status !== "awaiting_human_review") {
2437+
throw new Error("This workflow run is not awaiting supervisor review.");
2438+
}
2439+
if (!currentStep || currentStep.type !== "request_human_review" || !currentStepRow || !reviewContext) {
2440+
throw new Error("This workflow run is not on a human review step.");
2441+
}
24322442
updateRun(run.id, {
24332443
reviewState: reviewContext?.rejectAction === "loop_back" ? "changes_requested" : "rejected",
24342444
latestReviewNote: note ?? "Rejected by reviewer.",

apps/desktop/src/main/services/cto/linearSync.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,6 +2263,66 @@ describe("linearDispatcherService (file group)", () => {
22632263
db.close();
22642264
});
22652265

2266+
it.each(["approve", "reject"] as const)("rejects early %s before the supervisor review gate is active", async (action) => {
2267+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-early-review-action-"));
2268+
const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any);
2269+
const policy = buildSupervisedWorkerPolicy();
2270+
2271+
const dispatcher = createLinearDispatcherService({
2272+
db,
2273+
projectId: "project-1",
2274+
issueTracker: {
2275+
fetchIssueById: vi.fn(async () => issueFixture),
2276+
fetchWorkflowStates: vi.fn(async () => []),
2277+
updateIssueState: vi.fn(async () => {}),
2278+
addLabel: vi.fn(async () => {}),
2279+
createComment: vi.fn(async () => ({ commentId: "comment-1" })),
2280+
} as any,
2281+
workerAgentService: {
2282+
listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]),
2283+
} as any,
2284+
workerHeartbeatService: {
2285+
triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })),
2286+
listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]),
2287+
} as any,
2288+
agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any,
2289+
laneService: {
2290+
ensurePrimaryLane: vi.fn(async () => {}),
2291+
list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]),
2292+
create: vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })),
2293+
} as any,
2294+
templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any,
2295+
closeoutService: { applyOutcome: vi.fn(async () => {}) } as any,
2296+
outboundService: createOutboundServiceMocks(),
2297+
workerTaskSessionService: {
2298+
deriveTaskKey: vi.fn(() => "task-key-1"),
2299+
ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })),
2300+
} as any,
2301+
prService: {
2302+
getForLane: vi.fn(() => null),
2303+
createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })),
2304+
} as any,
2305+
});
2306+
2307+
const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend-supervised"] }, buildMatch(policy));
2308+
await dispatcher.advanceRun(run.id, policy);
2309+
2310+
expect(run.id).toBeTruthy();
2311+
const detailBeforeReview = await dispatcher.getRunDetail(run.id, policy);
2312+
expect(detailBeforeReview?.run.status).not.toBe("awaiting_human_review");
2313+
2314+
await expect(
2315+
dispatcher.resolveRunAction(run.id, action, "Pre-resolved.", policy),
2316+
).rejects.toThrow("not awaiting supervisor review");
2317+
const detailAfterRejectedAction = await dispatcher.getRunDetail(run.id, policy);
2318+
expect(detailAfterRejectedAction?.run.status).toBe(detailBeforeReview?.run.status);
2319+
expect(detailAfterRejectedAction?.run.reviewState).not.toBe(action === "approve" ? "approved" : "rejected");
2320+
2321+
const awaitingReview = await dispatcher.advanceRun(run.id, policy);
2322+
expect(awaitingReview?.status).toBe("awaiting_human_review");
2323+
db.close();
2324+
});
2325+
22662326
it("loops back to delegated work when supervisor requests changes", async () => {
22672327
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-loopback-"));
22682328
const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any);

docs/features/linear-integration/dispatch-and-sync.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,11 @@ A run's steps advance via `currentStepIndex`. For each step type:
205205
on timeout the step is marked failed with `review_timeout` and the
206206
run advances rather than stalling. `rejectAction` drives the
207207
rejection path (`cancel`, `reopen_issue`, or `loop_back` which
208-
resets `currentStepIndex` to `loopToStepId ?? "launch"`).
208+
resets `currentStepIndex` to `loopToStepId ?? "launch"`). Approve
209+
and reject actions are only valid while the run status is
210+
`awaiting_human_review` and the current step row is still a
211+
`request_human_review` step; early or stale resolutions throw
212+
without mutating `reviewState`.
209213
- `emit_app_notification` — broadcasts a
210214
`linear-workflow-notification` event via
211215
`ctoLinearWorkflowEvent` IPC. The renderer listens in `CtoPage` and

docs/features/remote-runtime/internal-architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for
3737

3838
Each `stream_events` response carries a per-runtime `eventEpoch` UUID minted when the daemon's `eventBuffer` is constructed. The preload event pump compares it against the last seen epoch for the active binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from `cursor=0`. The `startedAtMs` "drop events older than the pump start" filter is only applied to **local** bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered.
3939

40-
The remote event allowlist (`toRemoteRuntimeBufferedEvent`) accepts the runtime event categories desktop renders today: `category in {agent_chat, terminal, lane, pr, file_watch, process, test, project_state, orchestrator, dag_mutation, runtime, pty}`. The runtime additionally emits source-tagged events that preload routes to dedicated remote subscribers: `usage`, `usage_threshold`, `automation_event`, `conflict_event`, `github_status_changed`, `linear_workflow_event`, `feedback_submission_event`, `computer_use_event`, `ios_simulator_event`, `app_control_event`, and `macos_vm` (re-keyed to its `eventType`). ade-cli wires these into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, App Control, and macOS VM events as the local host.
40+
The remote event allowlist (`toRemoteRuntimeBufferedEvent`) accepts the runtime event categories desktop renders today: `category in {agent_chat, terminal, lane, pr, file_watch, process, test, project_state, orchestrator, dag_mutation, runtime, pty}`. The runtime additionally emits source-tagged events that preload routes to dedicated remote subscribers: `usage`, `usage_threshold`, `automation_event`, `conflict_event`, `github_status_changed`, `linear_workflow_event`, `feedback_submission_event`, `computer_use_event`, `ios_simulator_event`, `app_control_event`, and `macos_vm` (re-keyed to its `eventType`). ade-cli wires these into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, App Control, and macOS VM events as the local host. Headless runtimes start `usageTrackingService` during `createAdeRuntime()` after the ADE action registry is bound, so the usage poller and threshold events run only once the runtime can answer the matching usage/budget actions.
4141

4242
## SSH transport
4343

0 commit comments

Comments
 (0)