From 3b17b5ce15c906f9f67b9d44630a1457fe2705ce Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:02:06 -0600 Subject: [PATCH] fix(native): keep a reassigned-away run from closing the new owner's task (#19186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A native agent run reports "done" in an accepted result. A finalizer then decides the task status. > - The board can reassign a task while the run is in flight. The old run is cancelled, but its accepted result still goes to the finalizer. > - The finalizer did not check who owns the task now. The old owner's "done" closed the task of the new owner. > - The new owner never did the work, and the task shows done with no reply from the current owner. > - This pull request makes a done report apply only when the reporting run's agent still owns the task. > - The benefit is that reassignment keeps its meaning: the new owner keeps the open task, and the old result stays as history. ## Linked Issues or Issue Description **What happened?** On a hosted staging instance, agent A started a native run on a task. Agent A finished the work and submitted a "done" result. Then the board reassigned the task to agent B, and Paperclip cancelled agent A's run with `issue_reassigned`. About 45 seconds later, the native finalizer committed agent A's done decision with reason `completion_claim_policy_accepted`. The task became done. Agent B's runs were then cancelled because the task was terminal. Agent B never worked on the task. **Expected behavior** A done report from a run that no longer owns the task must not close the task. The accepted result stays as history. The task status stays as it is for the current owner. **Steps to reproduce** 1. Assign a low-risk native task to agent A and start a run. 2. Submit a done result for the run. 3. Reassign the task to agent B before finalization. 4. Finalize the run. Before this change, the task becomes done. **Paperclip version or commit** Observed on staging at `b9fbbf8d30`. Reproduced on `master` at `06826915d7`. Related: #19130 adds a publication check for requested documents at finalization. It does not check task ownership. ## What Changed - `status-arbiter.ts`: add `ownsNativeTaskCompletion` and `supersedeStaleOwnerCompletion`. A done decision from an agent that is not the current assignee (another agent, a user, or nobody) becomes `preserve` with reason `completion_owner_superseded` and no effects. Other decisions do not change. This is the same ownership rule that the provider-failure path already uses (`ownsProviderFailureDecision`). - `native-run-finalizer.ts`: apply the rule after arbitration and before the deliverable check. Conversation tasks keep their existing path. - `native-status-reassessment.ts`: apply the same rule, so new evidence from a reassigned-away run cannot close the task later. - The status committer does not change. A reassignment during finalization already increments `statusVersion` (migration 0358). The committer then replans, and the retry applies the new rule. - `doc/execution-semantics.md`: describe the rule next to the existing "a late finalizer cannot change a reassigned task" statement. ## Verification New file `server/src/services/native-runtime/native-stale-owner-completion.test.ts`. It uses embedded PostgreSQL, the real coordinator store and the real finalizer. - Control: the owning run's done result closes the task with `completion_claim_policy_accepted`. - Reassigned before finalization: the task stays `in_progress` with agent B. The decision is `completion_owner_superseded`. The accepted result row is unchanged. - Reassigned after arbitration and before the status commit: the commit replans and the task stays `in_progress`. - Reassessment control: later approved evidence closes the owner's task with `decision_superseded_by_new_evidence`. - Reassessment after reassignment: later approved evidence from the old run does not close the task. The decision is `completion_owner_superseded`. This case fails when the reassessment rule is disabled. - Pure policy cases: another agent, a board user and no assignee all preserve status. A non-done decision passes through. Red before the fix (same tests, fix not applied): ```sh cd server && npx vitest run src/services/native-runtime/native-stale-owner-completion.test.ts # Tests 2 failed | 1 passed — both reassignment cases: expected "in_progress", received "done" ``` Green after the fix: ```sh cd server && npx vitest run src/services/native-runtime/native-stale-owner-completion.test.ts # Tests 10 passed cd server && npx vitest run src/services/native-runtime/paperclip-control-plane-port.test.ts \ src/services/native-runtime/native-completion-feedback.test.ts src/services/native-runtime/native-deliverable-feedback.test.ts \ src/services/native-runtime/native-skill-test-status.test.ts src/services/native-runtime/native-routine-status.test.ts \ src/__tests__/native-status-arbiter-corpus.test.ts src/__tests__/native-status-reassessment-work.test.ts \ src/services/native-runtime/agent-chat-response-recovery.test.ts src/__tests__/native-run-projection-work.test.ts \ src/services/native-runtime/task-reassignment.test.ts src/services/native-runtime/native-provider-failure.integration.test.ts # 11 files, 614 tests passed ``` I did not run the full `pnpm test:run`, `pnpm -r typecheck` or `pnpm build` locally. CI runs them. ## Risks - A native run that reassigns its own task to a user or to another agent, and then reports done, no longer closes the task through arbitration. The status that the agent set through the API stays. This matches the existing provider-failure ownership rule. - A native run on a task with no assignee cannot close it through arbitration. Native task runs start from an assignment, so this is not expected in normal use. - Review runs and conversation tasks keep their existing paths. Review outcomes already preserve status before this rule. - No schema change and no migration. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), in the Claude Code agent harness. - Capabilities used: tool use, shell and code execution, repository search, browser reads of staging, and extended reasoning. - The model wrote the change, the tests and this description. A human requested the fix. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- doc/execution-semantics.md | 2 +- .../native-runtime/native-run-finalizer.ts | 6 + .../native-stale-owner-completion.test.ts | 183 ++++++++++++++++++ .../native-status-reassessment.ts | 6 +- .../services/native-runtime/status-arbiter.ts | 28 +++ 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 server/src/services/native-runtime/native-stale-owner-completion.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 4c4601dabc8..9e190669123 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2062,7 +2062,7 @@ results, intentional stops, and historical unmarked failures keep their existing terminal behavior. This continuation uses the already charged restart attempt and does not reset the provider-attempt budget. -Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade. +Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. In particular, a done report from a run whose task now belongs to another agent, a user or nobody is kept as an accepted result but records `completion_owner_superseded` and preserves the task status for the current owner. Historical ambiguous runs are never automatically replayed after an upgrade. When that deadline fails a native run with a prepared workspace, the same transaction records the exact source's file-preservation obligation. Existing diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index d5f850982ac..740e1908026 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -57,6 +57,7 @@ import type { PrpIgnoredAttentionRequest, PrpTerminalState } from "@paperclipai/ import { arbitrateNativeStatus, NATIVE_STATUS_ARBITER_POLICY_VERSION, + supersedeStaleOwnerCompletion, type NativeAuthoritativeIssueStatus, } from "./status-arbiter.js"; import { recordNativeWorkAssessment } from "./work-assessments.js"; @@ -1539,6 +1540,11 @@ export async function finalizeNativeRun(input: { proposedDecision = { ...proposedDecision, statusAction: "in_review", toStatus: "in_review", reasonCode: "onboarding_first_task_decision_required", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }; } + // A reassigned-away run keeps its accepted result, but cannot close the + // new owner's task. The status committer rechecks this under the issue lock. + if (!isConversation(authoritativeIssue)) { + proposedDecision = supersedeStaleOwnerCompletion(proposedDecision, authoritativeIssue, run.agentId); + } if (proposedDecision.statusAction === "done") { const continuation = record(run.contextSnapshot?.executionContinuation); const objective = typeof continuation.objective === "string" ? continuation.objective diff --git a/server/src/services/native-runtime/native-stale-owner-completion.test.ts b/server/src/services/native-runtime/native-stale-owner-completion.test.ts new file mode 100644 index 00000000000..3541caa00b3 --- /dev/null +++ b/server/src/services/native-runtime/native-stale-owner-completion.test.ts @@ -0,0 +1,183 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, completionContracts, createDb, heartbeatRuns, issueWorkProducts, issues, nativeRunFinalizations, + nativeRunResults, statusDecisions, type Db } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js"; +import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js"; +import { finalizeNativeRun } from "./native-run-finalizer.js"; +import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js"; +import { NATIVE_STATUS_ARBITER_POLICY_VERSION, supersedeStaleOwnerCompletion, type NativeStatusDecision } from "./status-arbiter.js"; + +const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_STALE_OWNER_TEST_DATABASE_URL"); +describe("stale owner completion policy", () => { + const done: NativeStatusDecision = { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "done", toStatus: "done", + reasonCode: "completion_claim_policy_accepted", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }; + const owner = { status: "in_progress", assigneeAgentId: "agent-a", assigneeUserId: null }; + + it("keeps the current owner's done decision", () => { + expect(supersedeStaleOwnerCompletion(done, owner, "agent-a")).toBe(done); + }); + it.each([ + ["another agent", { ...owner, assigneeAgentId: "agent-b" }], + ["a board user", { ...owner, assigneeAgentId: null, assigneeUserId: "user-1" }], + ["nobody", { ...owner, assigneeAgentId: null }], + ])("preserves status when the task now belongs to %s", (_label, issue) => { + expect(supersedeStaleOwnerCompletion(done, issue, "agent-a")).toEqual({ ...done, statusAction: "preserve", + toStatus: "in_progress", reasonCode: "completion_owner_superseded", effects: [] }); + }); + it("leaves non-terminal decisions to their existing owner checks", () => { + const blocked: NativeStatusDecision = { ...done, statusAction: "blocked", toStatus: "blocked", reasonCode: "x" }; + expect(supersedeStaleOwnerCompletion(blocked, { ...owner, assigneeAgentId: "agent-b" }, "agent-a")).toBe(blocked); + }); +}); + +const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); + +describe.skipIf(!support.supported)("native completion after reassignment", () => { + let database: Awaited> | undefined; + let db: Db; + beforeAll(async () => { + if (!external) database = await startEmbeddedPostgresTestDatabase("native-stale-owner-"); + db = createDb(external ?? database!.connectionString); + }, 60_000); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + /** + * A task whose original owner reported done in an accepted result. With + * pendingEvidence, the server arbiter waits for review of a work product. + */ + async function acceptedDoneResult(options: { pendingEvidence?: boolean } = {}) { + const companyId = randomUUID(), agentId = randomUUID(), successorId = randomUUID(), issueId = randomUUID(); + const runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Stale owner fixture", issuePrefix: companyId.slice(0, 8) }); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Original owner", adapterType: "paperclip_runner" }, + { id: successorId, companyId, name: "New owner", adapterType: "paperclip_runner" }, + ]); + await db.insert(issues).values({ id: issueId, companyId, title: "FizzBuzz variants", status: "in_progress", + assigneeAgentId: agentId, description: "Write fizzbuzz with tests and mark done." }); + const [contract] = await db.insert(completionContracts).values({ companyId, issueId, revision: 1, + schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: options.pendingEvidence ? "standard" : "low", + completionAuthority: options.pendingEvidence ? "server_arbiter" : "agent_claim_policy", + incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: { revision: "test", objective: "Write fizzbuzz", criteria: [{ id: "objective", requirement: "Complete the task" }] }, + canonicalSha256: `stale-owner-${issueId}`, createdByActorType: "system", createdByActorId: "test" }).returning(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, nativeSessionId: sessionId, + runnerInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256, + status: "running", runtimeMode: "native", contextSnapshot: { issueId, source: "issue.assigned" } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + const workProductId = randomUUID(); + if (options.pendingEvidence) await db.insert(issueWorkProducts).values({ id: workProductId, companyId, issueId, + type: "artifact", provider: "paperclip", title: "fizzbuzz.py", status: "ready_for_review", reviewState: "none" }); + const evidenceRef = options.pendingEvidence ? `work_product:${workProductId}` : `task:${issueId}`; + const port = new NativeRunCoordinatorStore(db, { companyId, issueId, runId, agentId, normalizedSessionId: sessionId, + runnerSourceInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256, + completionContractRevision: "test", completionContractCriterionIds: ["objective"] }); + await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", + summary: "Implemented fizzbuzz; 6 passed.", completionClaim: { contractRevision: "test", objectiveSatisfied: true, + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [evidenceRef] }], remainingWork: [] }, + evidence: [{ ref: evidenceRef }], + verification: [{ commandOrCheck: "pytest -q", status: "passed", detail: "6 passed", artifactRef: evidenceRef }], attentionRequests: [], artifacts: [] }, + terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", + reportedWorkDisposition: "done" }, turnId: "fizzbuzz-turn" }); + return { companyId, agentId, successorId, issueId, runId, workProductId }; + } + + /** The board's reassignment: new owner, and the old run loses execution. */ + async function reassign(f: Awaited>) { + await db.update(issues).set({ assigneeAgentId: f.successorId, executionRunId: null, checkoutRunId: null }) + .where(eq(issues.id, f.issueId)); + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned", finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, f.runId)); + } + + const issue = async (issueId: string) => (await db.select().from(issues).where(eq(issues.id, issueId)))[0]; + const decision = async (runId: string) => { + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)); + const [row] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, coordinator.decisionId!)); + return { coordinator, row }; + }; + + it("closes the task when the reporting run still owns it", async () => { + const f = await acceptedDoneResult(); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + expect(await issue(f.issueId)).toMatchObject({ status: "done", assigneeAgentId: f.agentId }); + expect((await decision(f.runId)).row.reasonCode).toBe("completion_claim_policy_accepted"); + }); + + it("does not let a reassigned-away run's done result close the new owner's task", async () => { + const f = await acceptedDoneResult(); + const [acceptedBefore] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)); + await reassign(f); + + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + + const current = await issue(f.issueId); + expect(current).toMatchObject({ status: "in_progress", assigneeAgentId: f.successorId }); + const { coordinator, row } = await decision(f.runId); + expect(coordinator.phase).toBe("committed"); + expect(row).toMatchObject({ toStatus: "in_progress", reasonCode: "completion_owner_superseded" }); + // The original owner's accepted result is kept as history, unchanged. + expect((await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)))[0]).toEqual(acceptedBefore); + }); + + it("does not close the task when reassignment lands after arbitration but before the status commit", async () => { + const f = await acceptedDoneResult(); + let reassigned = false; + // Reassign at the last finalizer write before the status transaction. + const racing = new Proxy(db, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property !== "update") return typeof value === "function" ? value.bind(target) : value; + return (table: unknown) => { + const builder = target.update(table as typeof nativeRunFinalizations); + if (table !== nativeRunFinalizations || reassigned) return builder; + const set = builder.set.bind(builder); + return Object.assign(builder, { set: (values: Record) => { + const next = set(values as never); + if (values.phase !== "arbitrating" || !("assessmentId" in values)) return next; + const where = next.where.bind(next); + return Object.assign(next, { where: (...args: Parameters) => { + const query = where(...args); + return { then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + query.then(async (result) => { reassigned = true; await reassign(f); return result; }).then(resolve, reject) }; + } }); + } }); + }; + }, + }) as Db; + + await finalizeNativeRun({ db: racing, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + + expect(reassigned).toBe(true); + expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", assigneeAgentId: f.successorId }); + expect((await decision(f.runId)).row).toMatchObject({ reasonCode: "completion_owner_superseded" }); + }); + async function approveEvidenceLater(f: Awaited>) { + await db.update(issueWorkProducts).set({ reviewState: "approved", updatedAt: new Date(Date.now() + 1_000) }) + .where(eq(issueWorkProducts.id, f.workProductId)); + } + + it("closes the owner's task when later evidence satisfies the contract", async () => { + const f = await acceptedDoneResult({ pendingEvidence: true }); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + expect((await issue(f.issueId)).status).not.toBe("done"); + await approveEvidenceLater(f); + await reconcileNativeFinalizations(db, [f.runId]); + expect(await issue(f.issueId)).toMatchObject({ status: "done", assigneeAgentId: f.agentId }); + expect((await decision(f.runId)).row.reasonCode).toBe("decision_superseded_by_new_evidence"); + }); + + it("does not let later evidence from a reassigned-away run close the new owner's task", async () => { + const f = await acceptedDoneResult({ pendingEvidence: true }); + await reassign(f); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + const [acceptedBefore] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)); + await approveEvidenceLater(f); + await reconcileNativeFinalizations(db, [f.runId]); + expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", assigneeAgentId: f.successorId }); + expect((await decision(f.runId)).row).toMatchObject({ toStatus: "in_progress", reasonCode: "completion_owner_superseded" }); + expect((await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)))[0]).toEqual(acceptedBefore); + }); +}); diff --git a/server/src/services/native-runtime/native-status-reassessment.ts b/server/src/services/native-runtime/native-status-reassessment.ts index 4da42937557..1bc389852a5 100644 --- a/server/src/services/native-runtime/native-status-reassessment.ts +++ b/server/src/services/native-runtime/native-status-reassessment.ts @@ -9,7 +9,7 @@ import { classifyNativeEvidence } from "./evidence-classifier.js"; import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js"; import { resolveNativeReconciliationStatus, type NativeReconciliationFacts } from "./native-reconciliation-status.js"; import { pendingNativeGovernance, resolveNativeFinalizerStatus } from "./native-run-finalizer.js"; -import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeAuthoritativeIssueStatus, type NativeStatusDecision } from "./status-arbiter.js"; +import { NATIVE_STATUS_ARBITER_POLICY_VERSION, supersedeStaleOwnerCompletion, type NativeAuthoritativeIssueStatus, type NativeStatusDecision } from "./status-arbiter.js"; import { commitNativeStatusDecision, NativeStatusRaceError } from "./status-decision-committer.js"; import { recordNativeWorkAssessment } from "./work-assessments.js"; import { readPreservedNativeResultFinalization } from "./preserved-native-result-finalization.js"; @@ -234,6 +234,10 @@ export async function reassessNativeStatusFromSource( decision = resolveNativeFinalizerStatus({ assessment: reassessment, terminalState: "succeeded", workspaceFinalizeStatus: "succeeded", requiredQualityReviewSatisfied: false, priorIssueStatus: row.issueStatus as NativeAuthoritativeIssueStatus, agentId: row.agentId }); } + // New evidence from a reassigned-away run cannot close the new owner's task. + if (currentIssue && !currentIssue.conversationAgentId) { + decision = supersedeStaleOwnerCompletion(decision, { ...currentIssue, status: row.issueStatus }, row.agentId); + } let committed: Awaited>; try { committed = await commitNativeStatusDecision({ diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index 008ca97b44b..e50ff7b66ed 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -91,6 +91,34 @@ export interface NativeStatusDecision { effects: NativeStatusEffect[]; } +/** + * Completion is the current owner's decision. A run whose task was reassigned + * away (to another agent or to a user) keeps its accepted result as history, + * but its done report cannot close the new owner's task. + */ +export function ownsNativeTaskCompletion( + issue: { assigneeAgentId: string | null; assigneeUserId: string | null }, + agentId: string, +) { + return issue.assigneeAgentId === agentId && !issue.assigneeUserId; +} + +export function supersedeStaleOwnerCompletion( + decision: NativeStatusDecision, + issue: { status: string; assigneeAgentId: string | null; assigneeUserId: string | null }, + agentId: string, +): NativeStatusDecision { + if (decision.statusAction !== "done" || ownsNativeTaskCompletion(issue, agentId)) return decision; + return { + policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, + statusAction: "preserve", + toStatus: issue.status as NativeAuthoritativeIssueStatus, + reasonCode: "completion_owner_superseded", + unblockDescriptor: null, + effects: [], + }; +} + /** Pure authority boundary: model prose is evidence, never a status command. */ export function arbitrateNativeStatus(input: { assessment: NativeEvidenceAssessment;