fix(native): keep a reassigned-away run from closing the new owner's task (#19186)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 00:02:06 -06:00
1 parent 46fb933b01
commit 3b17b5ce15
5 files changed
+223 -2

No files matched your search

+1 -1
View File
@@ -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 terminal behavior. This continuation uses the already charged restart attempt and
does not reset the provider-attempt budget. 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 When that deadline fails a native run with a prepared workspace, the same
transaction records the exact source's file-preservation obligation. Existing transaction records the exact source's file-preservation obligation. Existing
@@ -57,6 +57,7 @@ import type { PrpIgnoredAttentionRequest, PrpTerminalState } from "@paperclipai/
import { import {
arbitrateNativeStatus, arbitrateNativeStatus,
NATIVE_STATUS_ARBITER_POLICY_VERSION, NATIVE_STATUS_ARBITER_POLICY_VERSION,
supersedeStaleOwnerCompletion,
type NativeAuthoritativeIssueStatus, type NativeAuthoritativeIssueStatus,
} from "./status-arbiter.js"; } from "./status-arbiter.js";
import { recordNativeWorkAssessment } from "./work-assessments.js"; import { recordNativeWorkAssessment } from "./work-assessments.js";
@@ -1539,6 +1540,11 @@ export async function finalizeNativeRun(input: {
proposedDecision = { ...proposedDecision, statusAction: "in_review", toStatus: "in_review", proposedDecision = { ...proposedDecision, statusAction: "in_review", toStatus: "in_review",
reasonCode: "onboarding_first_task_decision_required", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }; 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") { if (proposedDecision.statusAction === "done") {
const continuation = record(run.contextSnapshot?.executionContinuation); const continuation = record(run.contextSnapshot?.executionContinuation);
const objective = typeof continuation.objective === "string" ? continuation.objective const objective = typeof continuation.objective === "string" ? continuation.objective
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<ReturnType<typeof acceptedDoneResult>>) {
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<string, unknown>) => {
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<typeof where>) => {
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<ReturnType<typeof acceptedDoneResult>>) {
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);
});
});
@@ -9,7 +9,7 @@ import { classifyNativeEvidence } from "./evidence-classifier.js";
import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js"; import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js";
import { resolveNativeReconciliationStatus, type NativeReconciliationFacts } from "./native-reconciliation-status.js"; import { resolveNativeReconciliationStatus, type NativeReconciliationFacts } from "./native-reconciliation-status.js";
import { pendingNativeGovernance, resolveNativeFinalizerStatus } from "./native-run-finalizer.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 { commitNativeStatusDecision, NativeStatusRaceError } from "./status-decision-committer.js";
import { recordNativeWorkAssessment } from "./work-assessments.js"; import { recordNativeWorkAssessment } from "./work-assessments.js";
import { readPreservedNativeResultFinalization } from "./preserved-native-result-finalization.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", decision = resolveNativeFinalizerStatus({ assessment: reassessment, terminalState: "succeeded", workspaceFinalizeStatus: "succeeded",
requiredQualityReviewSatisfied: false, priorIssueStatus: row.issueStatus as NativeAuthoritativeIssueStatus, agentId: row.agentId }); 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<ReturnType<typeof commitNativeStatusDecision>>; let committed: Awaited<ReturnType<typeof commitNativeStatusDecision>>;
try { try {
committed = await commitNativeStatusDecision({ committed = await commitNativeStatusDecision({
@@ -91,6 +91,34 @@ export interface NativeStatusDecision {
effects: NativeStatusEffect[]; 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. */ /** Pure authority boundary: model prose is evidence, never a status command. */
export function arbitrateNativeStatus(input: { export function arbitrateNativeStatus(input: {
assessment: NativeEvidenceAssessment; assessment: NativeEvidenceAssessment;