mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep a finalization row, and tasks can keep recovery
actions while work is stuck
> - Git cache expiration and native harness backup expiration read these
rows to decide if closed work is still live
> - Commit paths never cleared
`native_run_finalizations.recovery_state`, so committed rows kept
`resuming_session` forever
> - Recovery actions on done or cancelled tasks also stayed active when
the close did not go through the issue routes
> - So workspaces and backups of tasks that closed weeks ago stayed live
and used disk space
> - This pull request clears the stale state at commit, backfills old
rows, resolves stale recovery actions on closed tasks, and lets the two
expiration jobs ignore settled state on closed trees
> - The benefit is that closed work expires on schedule, while live
leases, unsettled phases, unverified owners, and in-flight system
recovery still keep it
## Linked Issues or Issue Description
Refs #17248
**What happened?**
On one instance, Git cache expiration and native harness backup
expiration kept the workspaces and backups of tasks that were done for
more than 7 days. Three kinds of stale state caused this:
1. 51 `native_run_finalizations` rows had `phase = 'committed'` and
`recovery_state = 'resuming_session'`. The commit writes (status
decision commit, attention replay, result persistence, and the finalizer
claim) never cleared `recovery_state`. No reader uses `recovery_state`
on a row that has a result, except the expiration predicates.
2. Rows with `phase = 'terminal_failure'` and `recovery_state =
'blocked'` (or NULL) are permanent states. Nothing clears them, and
continuations are refused for closed tasks.
3. `issue_recovery_actions` with status `active` or `escalated` stayed
open after the source task became done or cancelled. Only the issue
routes cancel them at close. The periodic sweep
`reconcileActiveRecoveryActions` skipped every action unless
`wakePolicy.type` was a bounded owner policy or the owner was the board.
Agent-owned `resume_native_run` actions (which use `wakePolicy.kind`)
and system-owned actions were never resolved.
**Expected behavior**
When a task tree is closed for the configured time, its Git cache and
native backup expire. Only live state keeps them: a held finalization
lease, an unsettled finalization phase, an unverified native workspace
owner, or system recovery that is scheduled, running, or awaiting
evidence.
**Steps to reproduce**
1. Run a native task. Let the runner restart so that restart recovery
sets `recovery_state = 'resuming_session'` on the finalization row.
2. Let the resumed run commit its result and close the task as done.
3. Wait past the expiration window (or run the sweep with a later
clock).
4. See that the finalization row is still `committed` +
`resuming_session`, and that Git cache expiration and native backup
expiration skip the closed workspace.
**Paperclip version or commit**
`master` at 837add0caa
**Deployment mode**
Self-hosted server with native runtime enabled
**Agent adapter(s) involved**
Not adapter-specific (core bug)
## What Changed
- Clear `recovery_state` and `recovery_request_id` in every write that
persists or commits a native result: `recordCoordinatorDecision` in the
status decision committer, the attention replay in
`routePersistedNativeResultAttention`, `completeRun` in both
`NativeRunCoordinatorStore` and `PaperclipControlPlanePort`, and
`claimCoordinator` in the finalizer.
- Add data migration `0376_clear_committed_native_recovery_state.sql`
with its unchanged schema snapshot (the oldest snapshot is pruned, as
the generator does). It clears `recovery_state` and
`recovery_request_id` on rows that are `committed` with no lease owner.
`recovery_history` keeps the audit trail.
- In `reconcileActiveRecoveryActions`, resolve active and escalated
actions on done or cancelled tasks (`source_terminal`) before the wake
policy and owner filter. The existing owner-unverified guard still runs
first. Actions with system recovery in `scheduled`, `running`, or
`awaiting_evidence` are skipped.
- In `isGitWorkspaceExpirationEligible` (closed-tree mode only), a
recovery action blocks only if its cause is
`native_workspace_finalization_owner_unverified` or its system recovery
is in flight. A finalization blocks only if it has a lease owner or its
phase is not `committed` or `terminal_failure`. Idle mode keeps the old
strict checks.
- In native harness backup expiration, a settled finalization
(`committed` or `terminal_failure`, no lease owner) no longer blocks
once the session's tasks are closed for the configured days.
## Verification
- `pnpm exec vitest run` in `server/` on the touched and related files:
`git-workspace-expiration`, `git-workspace-expiration-eligibility`,
`native-harness-backup-expiration`, `native-finalization-migration`,
`native-interaction-bridge`, `native-run-finalizer-telemetry`,
`paperclip-control-plane-port`, `issue-recovery-actions`,
`native-status-arbiter-corpus`: 9 files, 321 tests passed. After the
tests moved to `native-finalization-recovery-state.test.ts`, that file,
`git-workspace-expiration` and `native-run-finalizer-telemetry` passed
again (95 tests).
- `pnpm exec vitest run src/migration-snapshot-drift.test.ts` in
`packages/db`: passed.
- `pnpm exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts`: 408 tests passed.
- `pnpm run typecheck` in `server/`: passed.
- `pnpm run check:migrations` in `packages/db`: passed.
- New tests:
- Result persistence (port and coordinator store), finalizer claim and
status decision commit (new focused file
`native-finalization-recovery-state.test.ts`), and attention replay each
clear `recovery_state` and `recovery_request_id`.
- The migration clears committed, unleased rows. It keeps leased
committed rows, `observed` rows, and `terminal_failure` rows unchanged,
and keeps `recovery_history`.
- The sweep resolves an agent-owned `resume_native_run` action and an
escalated system action on a done task, and keeps an action with
scheduled system recovery. The existing test "retains the exact native
owner hold without changing a terminal task's disposition" still covers
the owner-unverified hold.
- Git expiration eligibility: a closed tree expires with `committed` +
`resuming_session`, `terminal_failure` + `blocked`, an active agent
recovery action, or an escalated system action. It stays with a leased
finalization, an `observed` finalization, an owner-unverified hold,
in-flight system recovery, an open tree, and in idle mode.
- A full Git expiration of a closed tree with stale native state, then a
reopen to the recorded commit. The existing "preserves scheduled runs,
unresolved recovery, and live previews" test now uses an `observed` and
a leased finalization as the unresolved recovery.
- Native backup expiration expires `committed` + `resuming_session` and
`terminal_failure` + `blocked`, and keeps an `observed` or leased
finalization.
- Mutation checks. I reverted each fix on its own, saw the matching test
fail, and restored it:
- Status decision commit, finalizer claim, port `completeRun`, store
`completeRun`, attention replay: each new clearing test failed.
- Migration (`WHERE false`): the backfill test failed.
- Sweep order (wake policy filter moved back in front) and the in-flight
system recovery guard: the sweep test failed.
- Git eligibility: the closed-tree action relaxation, the finalization
relaxation, the owner-unverified guard, and idle strictness each made
the matching eligibility tests fail.
- Backup expiration (`recovery_state IS NOT NULL` restored): both new
expiry cases failed.
- Not run: the full server suite and the e2e suites. CI runs them.
## Risks
- The migration is a data-only `UPDATE` on `native_run_finalizations`.
It touches only committed rows with no lease owner and a non-null
`recovery_state`. The table is not on the known-large list, and the
migration safety check passes. `recovery_history` is not changed.
- Clearing `recovery_state` at commit and claim: the readers of this
column need `phase = 'observed'` (continuation contract), `result_id IS
NULL` (recovery service, failed-run retry), or running runs (heartbeat
recovery). A row that has a result does not match any of them. The
restart recovery summary counts will drop because they no longer count
committed rows.
- The sweep now resolves more actions on closed tasks. A done or
cancelled task cannot continue, so these actions cannot act. Unverified
owner holds and in-flight system recovery are kept.
- Expiration now removes more closed workspaces and backups. Restore
points and Git cache restoration still allow a reopen to rebuild the
workspace.
> 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
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests).
## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
960 lines
41 KiB
TypeScript
960 lines
41 KiB
TypeScript
import type { Db } from "@paperclipai/db";
|
|
import type { NativeInteractionResponseEnvelope } from "../../vendor/paperclip-runner/index.js";
|
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
toolActionRequests,
|
|
toolInvocations,
|
|
heartbeatRuns,
|
|
issues,
|
|
issueThreadInteractions,
|
|
nativeRunFinalizations,
|
|
nativeRunResults,
|
|
statusDecisions,
|
|
workAssessments,
|
|
} from "@paperclipai/db";
|
|
import { getAgentWorkEligibility } from "@paperclipai/shared";
|
|
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
|
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
|
import { nativeSha256 } from "./canonical.js";
|
|
import { recordNativeAttentionAssessment } from "./work-assessments.js";
|
|
import { formatDurableQuestionResponseSummary } from "../question-response-delivery.js";
|
|
import { findSatisfiedToolConnection } from "../satisfied-connection-intents.js";
|
|
import {
|
|
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
type NativeAuthoritativeIssueStatus,
|
|
type NativeGovernanceGate,
|
|
type NativeStatusDecision,
|
|
type NativeStatusEffect,
|
|
} from "./status-arbiter.js";
|
|
|
|
export class NativeInteractionBridgeError extends Error {
|
|
constructor(readonly code: string, message: string) {
|
|
super(message);
|
|
this.name = "NativeInteractionBridgeError";
|
|
}
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
export type NativeAttentionMaterializedTarget = {
|
|
effectKind: string;
|
|
targetType: string;
|
|
targetId: string;
|
|
};
|
|
|
|
/**
|
|
* Applies audit-only attention outcomes that intentionally create no status
|
|
* decision. The durable interaction/coordinator mutation is the proof; a
|
|
* synthetic delivered effect row is neither created nor accepted.
|
|
*/
|
|
export async function applyNativeAttentionStatusDecision(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
decision: NativeStatusDecision;
|
|
targetInteractionId?: string;
|
|
canonicalRequestId?: string;
|
|
}): Promise<NativeAttentionMaterializedTarget[]> {
|
|
if (input.decision.statusAction !== "preserve") {
|
|
throw new NativeInteractionBridgeError("native_attention_status_authority_invalid", "Audit-only attention cannot change issue status");
|
|
}
|
|
return input.db.transaction(async (tx) => {
|
|
const targets: NativeAttentionMaterializedTarget[] = [];
|
|
for (const effect of input.decision.effects) {
|
|
if (effect.kind === "link_canonical_request") {
|
|
const boundIds = [input.canonicalRequestId, input.targetInteractionId]
|
|
.filter((id): id is string => typeof id === "string");
|
|
const interactions = await tx.select({
|
|
id: issueThreadInteractions.id,
|
|
summary: issueThreadInteractions.summary,
|
|
})
|
|
.from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
...(boundIds.length > 0 ? [inArray(issueThreadInteractions.id, boundIds)] : []),
|
|
)).limit(2);
|
|
const canonical = input.canonicalRequestId
|
|
? interactions.find((interaction) => interaction.id === input.canonicalRequestId)
|
|
: interactions[0];
|
|
const duplicate = input.targetInteractionId
|
|
? interactions.find((interaction) => interaction.id === input.targetInteractionId)
|
|
: interactions.find((interaction) => interaction.id !== canonical?.id);
|
|
if (!canonical || !duplicate || canonical.id === duplicate.id) {
|
|
throw new NativeInteractionBridgeError("native_attention_canonical_pair_missing", "Canonical and duplicate requests are required");
|
|
}
|
|
const summary = `Canonical native attention request: ${canonical.id}`;
|
|
if (duplicate.summary !== summary) {
|
|
await tx.update(issueThreadInteractions).set({
|
|
summary,
|
|
updatedAt: new Date(),
|
|
}).where(eq(issueThreadInteractions.id, duplicate.id));
|
|
}
|
|
targets.push({ effectKind: effect.kind, targetType: "issue_thread_interaction", targetId: duplicate.id });
|
|
continue;
|
|
}
|
|
if (effect.kind === "record_stale_response") {
|
|
const interaction = await tx.select({
|
|
id: issueThreadInteractions.id,
|
|
summary: issueThreadInteractions.summary,
|
|
})
|
|
.from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
...(input.targetInteractionId ? [eq(issueThreadInteractions.id, input.targetInteractionId)] : []),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (!interaction) throw new NativeInteractionBridgeError("native_stale_response_missing", "Stale response is not durable");
|
|
const summary = "Response retained for audit after native supersession.";
|
|
if (interaction.summary !== summary) {
|
|
await tx.update(issueThreadInteractions).set({
|
|
summary,
|
|
updatedAt: new Date(),
|
|
}).where(eq(issueThreadInteractions.id, interaction.id));
|
|
}
|
|
targets.push({ effectKind: effect.kind, targetType: "issue_thread_interaction", targetId: interaction.id });
|
|
continue;
|
|
}
|
|
if (effect.kind === "record_finalization_error") {
|
|
const [coordinator] = await tx.update(nativeRunFinalizations).set({
|
|
phase: "terminal_failure",
|
|
failureCode: effect.cause,
|
|
failureDetail: { nextAction: effect.nextAction, recoveryOwner: { kind: "agent", agentId: effect.agentId } },
|
|
nextAttemptAt: null,
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(nativeRunFinalizations.companyId, input.companyId),
|
|
eq(nativeRunFinalizations.issueId, input.issueId),
|
|
)).returning({ runId: nativeRunFinalizations.runId });
|
|
if (!coordinator) throw new NativeInteractionBridgeError("native_finalization_coordinator_missing", "Native coordinator is required");
|
|
await tx.update(heartbeatRuns).set({
|
|
nativePhase: "terminal_failure",
|
|
nativePhaseUpdatedAt: new Date(),
|
|
errorCode: effect.cause,
|
|
updatedAt: new Date(),
|
|
}).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
|
targets.push({ effectKind: effect.kind, targetType: "native_run_finalization", targetId: coordinator.runId });
|
|
continue;
|
|
}
|
|
throw new NativeInteractionBridgeError("native_attention_effect_unimplemented", effect.kind);
|
|
}
|
|
return targets;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Materialize only interactions that authorized this run's wake. Resolution
|
|
* remains owned by the existing interaction service; this bridge is a
|
|
* read-only, identity-bound projection and never receives credentials.
|
|
*/
|
|
export async function materializeNativeInteractionResponses(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
agentId: string;
|
|
interactionIds: string[];
|
|
}): Promise<NativeInteractionResponseEnvelope[]> {
|
|
const requestedIds = new Set(input.interactionIds.filter(Boolean));
|
|
if (requestedIds.size === 0) return [];
|
|
const [interactions, issue] = await Promise.all([
|
|
issueThreadInteractionService(input.db).listForIssue(input.issueId),
|
|
input.db.select().from(issues).where(and(
|
|
eq(issues.id, input.issueId),
|
|
eq(issues.companyId, input.companyId),
|
|
)).limit(1).then((rows) => rows[0] ?? null),
|
|
]);
|
|
if (!issue) throw new NativeInteractionBridgeError("native_interaction_binding_mismatch", "Issue binding not found");
|
|
const responses: NativeInteractionResponseEnvelope[] = [];
|
|
|
|
for (const interaction of interactions) {
|
|
if (!requestedIds.has(interaction.id)) continue;
|
|
if (interaction.companyId !== input.companyId || interaction.issueId !== input.issueId) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_binding_mismatch",
|
|
`Interaction ${interaction.id} is not bound to the native company and issue`,
|
|
);
|
|
}
|
|
if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) {
|
|
const action = interaction.payload.toolAction;
|
|
if (["accepted", "rejected"].includes(interaction.status) && (interaction.resolvedByAgentId || interaction.resolvedByRunId === input.runId)) {
|
|
throw new NativeInteractionBridgeError("native_interaction_self_approval", "Agents cannot resolve governed tool reviews");
|
|
}
|
|
const [request] = await input.db.select().from(toolActionRequests).where(and(eq(toolActionRequests.id, action.actionRequestId), eq(toolActionRequests.companyId, input.companyId), eq(toolActionRequests.issueId, input.issueId), eq(toolActionRequests.interactionId, interaction.id), eq(toolActionRequests.invocationId, action.invocationId)));
|
|
const [invocation] = await input.db.select().from(toolInvocations).where(and(eq(toolInvocations.id, action.invocationId), eq(toolInvocations.companyId, input.companyId), eq(toolInvocations.issueId, input.issueId), eq(toolInvocations.agentId, input.agentId)));
|
|
if (!request || !invocation || request.requestedByAgentId !== input.agentId || request.canonicalArgumentsHash !== action.argumentsHash) {
|
|
throw new NativeInteractionBridgeError("native_interaction_governed_request_unresolved", "Tool review has no matching authoritative invocation");
|
|
}
|
|
if (["expired", "cancelled"].includes(request.status)) {
|
|
if (interaction.status !== request.status && !(interaction.status === "accepted" && interaction.result?.toolAction?.status === "expired")) throw new NativeInteractionBridgeError("native_interaction_governed_result_mismatch", "Tool review lifecycle does not match its request");
|
|
responses.push({ interactionId: interaction.id, kind: interaction.kind, response: { status: interaction.status, result: structuredClone(interaction.result), executionStatus: request.status } });
|
|
continue;
|
|
}
|
|
if (!request.decidedByUserId || request.decidedByUserId !== interaction.resolvedByUserId || !["executed", "failed", "rejected"].includes(request.status) || (request.status === "rejected" ? interaction.status !== "rejected" : interaction.status !== "accepted")) {
|
|
throw new NativeInteractionBridgeError("native_interaction_governed_request_unresolved", "Tool review must have a human decision and an authoritative terminal execution outcome");
|
|
}
|
|
const expectedInvocationStatus = request.status === "executed" ? "succeeded" : request.status === "rejected" ? "denied" : "failed";
|
|
if (invocation.status !== expectedInvocationStatus || (request.status !== "rejected" && interaction.result?.toolAction?.status !== request.status)) throw new NativeInteractionBridgeError("native_interaction_governed_result_mismatch", "Tool review outcome does not match its invocation");
|
|
}
|
|
const interactionResult = record(interaction.result);
|
|
if (interaction.kind === "connection_intent" && interaction.status === "expired"
|
|
&& interactionResult.outcome === "expired" && typeof interactionResult.connectionId === "string") {
|
|
const [run] = await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.agentId, input.agentId)));
|
|
const ready = issue.assigneeAgentId === input.agentId && run?.responsibleUserId === interaction.addresseeUserId
|
|
? await findSatisfiedToolConnection(input.db, issue, { ...interaction, addresseeUserId: interaction.addresseeUserId ?? null, status: "pending" }, input.runId) : null;
|
|
if (!ready || ready.id !== interactionResult.connectionId) {
|
|
throw new NativeInteractionBridgeError("native_interaction_unresolved", "The existing connection is no longer available");
|
|
}
|
|
responses.push({ interactionId: interaction.id, kind: interaction.kind,
|
|
response: { status: "expired", result: structuredClone(interaction.result) as unknown as Record<string, unknown> } });
|
|
continue;
|
|
}
|
|
const supersessionOutcome = interaction.status === "expired"
|
|
&& ["superseded_by_newer_request", "superseded_by_comment", "stale_target"].includes(String(interactionResult.outcome));
|
|
if (supersessionOutcome) {
|
|
const duplicate = interactionResult.outcome === "superseded_by_newer_request";
|
|
const decision = resolveNativeAttentionStatus({
|
|
facts: duplicate
|
|
? {
|
|
companyScopeValid: true,
|
|
responseState: "none",
|
|
route: "duplicate",
|
|
summary: interaction.summary ?? interaction.title ?? "Duplicate native attention request",
|
|
}
|
|
: {
|
|
companyScopeValid: true,
|
|
responseState: "stale",
|
|
route: "context",
|
|
summary: interaction.summary ?? interaction.title ?? "Stale native attention response",
|
|
},
|
|
priorIssueStatus: issue.status as NativeAuthoritativeIssueStatus,
|
|
agentId: input.agentId,
|
|
});
|
|
await applyNativeAttentionStatusDecision({
|
|
db: input.db,
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
runId: input.runId,
|
|
decision,
|
|
targetInteractionId: interaction.id,
|
|
canonicalRequestId: typeof interactionResult.supersededByInteractionId === "string"
|
|
? interactionResult.supersededByInteractionId
|
|
: undefined,
|
|
});
|
|
continue;
|
|
}
|
|
if (
|
|
interaction.resolvedByRunId === input.runId
|
|
|| (
|
|
interaction.resolvedByAgentId === input.agentId
|
|
&& interaction.createdByAgentId === input.agentId
|
|
)
|
|
) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_self_approval",
|
|
`Run ${input.runId} cannot consume a response it resolved itself`,
|
|
);
|
|
}
|
|
const hasInteractionResolver = Boolean(interaction.resolvedByUserId || interaction.resolvedByAgentId);
|
|
if (interaction.kind !== "request_item_verdicts" && !hasInteractionResolver) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_unresolved",
|
|
`Interaction ${interaction.id} has no authorized resolver`,
|
|
);
|
|
}
|
|
|
|
if (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation") {
|
|
if (interaction.resolvedByAgentId === input.agentId) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_self_approval",
|
|
`Agent ${input.agentId} cannot consume a confirmation it resolved`,
|
|
);
|
|
}
|
|
if (
|
|
(interaction.status !== "accepted" && interaction.status !== "rejected")
|
|
|| !interaction.result
|
|
|| (interaction.result.outcome !== "accepted" && interaction.result.outcome !== "rejected")
|
|
) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_unresolved",
|
|
`Confirmation ${interaction.id} is not authoritatively resolved`,
|
|
);
|
|
}
|
|
const resolution = resolveNativeAttentionStatus({
|
|
facts: {
|
|
companyScopeValid: true,
|
|
responseState: "resolved",
|
|
route: "context",
|
|
summary: interaction.summary ?? interaction.title ?? "Resolved native interaction",
|
|
},
|
|
priorIssueStatus: issue.status as NativeAuthoritativeIssueStatus,
|
|
agentId: input.agentId,
|
|
});
|
|
if (resolution.reasonCode !== "attention_resolved_from_context") {
|
|
throw new NativeInteractionBridgeError("native_attention_resolution_invalid", "Resolved interaction has no continuation policy");
|
|
}
|
|
responses.push({
|
|
interactionId: interaction.id,
|
|
kind: interaction.kind,
|
|
response: {
|
|
status: interaction.status,
|
|
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (interaction.kind === "suggest_tasks" || interaction.kind === "connection_intent") {
|
|
if (
|
|
(interaction.status !== "accepted" && interaction.status !== "rejected")
|
|
|| !interaction.result
|
|
) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_unresolved",
|
|
`Suggested-task interaction ${interaction.id} is not authoritatively resolved`,
|
|
);
|
|
}
|
|
responses.push({
|
|
interactionId: interaction.id,
|
|
kind: interaction.kind,
|
|
response: {
|
|
status: interaction.status,
|
|
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (interaction.kind === "ask_user_questions") {
|
|
if (interaction.status !== "answered" || !interaction.result || interaction.result.cancelled === true) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_unresolved",
|
|
`Question interaction ${interaction.id} is not authoritatively answered`,
|
|
);
|
|
}
|
|
const resolution = resolveNativeAttentionStatus({
|
|
facts: {
|
|
companyScopeValid: true,
|
|
responseState: "resolved",
|
|
route: "context",
|
|
summary: interaction.summary ?? interaction.title ?? "Answered native interaction",
|
|
},
|
|
priorIssueStatus: issue.status as NativeAuthoritativeIssueStatus,
|
|
agentId: input.agentId,
|
|
});
|
|
if (resolution.reasonCode !== "attention_resolved_from_context") {
|
|
throw new NativeInteractionBridgeError("native_attention_resolution_invalid", "Answered interaction has no continuation policy");
|
|
}
|
|
const result = structuredClone(interaction.result) as unknown as Record<string, unknown>;
|
|
const summaryMarkdown = formatDurableQuestionResponseSummary(interaction);
|
|
responses.push({
|
|
interactionId: interaction.id,
|
|
kind: interaction.kind,
|
|
response: {
|
|
status: interaction.status,
|
|
result: summaryMarkdown
|
|
? { ...result, summaryMarkdown }
|
|
: result,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (interaction.kind === "request_item_verdicts") {
|
|
const result = record(interaction.result);
|
|
const items = Array.isArray(result.items) ? result.items.map(record) : [];
|
|
const complete = result.complete === true;
|
|
const hasAuthorizedItemResolver = items.some((item) =>
|
|
typeof item.resolvedByUserId === "string"
|
|
|| (typeof item.resolvedByAgentId === "string" && item.resolvedByAgentId !== input.agentId));
|
|
const hasSelfResolvedItem = items.some((item) => item.resolvedByAgentId === input.agentId);
|
|
if (hasSelfResolvedItem) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_self_approval",
|
|
`Agent ${input.agentId} cannot consume item verdicts it resolved`,
|
|
);
|
|
}
|
|
if (
|
|
!interaction.result
|
|
|| (interaction.status !== "pending" && interaction.status !== "answered")
|
|
|| (interaction.status === "answered" && !complete)
|
|
|| items.length === 0
|
|
|| !hasAuthorizedItemResolver
|
|
) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_unresolved",
|
|
`Item-verdict interaction ${interaction.id} has no authoritative response`,
|
|
);
|
|
}
|
|
responses.push({
|
|
interactionId: interaction.id,
|
|
kind: interaction.kind,
|
|
response: {
|
|
status: interaction.status,
|
|
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
throw new NativeInteractionBridgeError(
|
|
"native_runtime_request_unsupported",
|
|
"Interaction kind is not supported by the native input contract",
|
|
);
|
|
}
|
|
|
|
const missing = [...requestedIds].filter((id) => !responses.some((response) => response.interactionId === id));
|
|
if (missing.length > 0) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_interaction_missing",
|
|
`Native interaction response not found: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
return responses.sort((left, right) => left.interactionId.localeCompare(right.interactionId));
|
|
}
|
|
|
|
export type LegacyQuestionResponseWakeProjection = {
|
|
interactionId: string;
|
|
summaryMarkdown: string;
|
|
};
|
|
|
|
/**
|
|
* Legacy adapters do not consume the runner's closed interaction-response
|
|
* envelope. Project an answered question set into their wake prompt at invoke
|
|
* time instead. The caller must keep this projection ephemeral so the
|
|
* interaction result remains the only control-plane copy of the answers.
|
|
*/
|
|
export async function materializeLegacyQuestionResponseWakeProjection(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
agentId: string;
|
|
interactionId: string;
|
|
}): Promise<LegacyQuestionResponseWakeProjection | null> {
|
|
const responses = await materializeNativeInteractionResponses({
|
|
...input,
|
|
interactionIds: [input.interactionId],
|
|
});
|
|
const response = responses.find((candidate) =>
|
|
candidate.interactionId === input.interactionId
|
|
&& candidate.kind === "ask_user_questions"
|
|
&& candidate.response.status === "answered"
|
|
);
|
|
if (!response) return null;
|
|
const summaryMarkdown = record(response.response.result).summaryMarkdown;
|
|
if (typeof summaryMarkdown !== "string" || summaryMarkdown.trim().length === 0) return null;
|
|
return {
|
|
interactionId: response.interactionId,
|
|
summaryMarkdown: summaryMarkdown.trim(),
|
|
};
|
|
}
|
|
|
|
/** Native runtimes never receive credentials and never auto-approve requests. */
|
|
export function rejectUnsupportedNativeRuntimeRequest(requestKind: string) {
|
|
return {
|
|
accepted: false as const,
|
|
code: "native_runtime_request_unsupported" as const,
|
|
requestKind,
|
|
credentialsInjected: false as const,
|
|
selfApproval: false as const,
|
|
};
|
|
}
|
|
|
|
export type NativeAttentionFacts = {
|
|
companyScopeValid: boolean;
|
|
responseState: "none" | "resolved" | "stale" | "expired";
|
|
route: "alternate_track" | "context" | "retry" | "agent" | "human" | "duplicate" | "recovery";
|
|
summary: string;
|
|
budgetExhausted?: boolean;
|
|
governanceGate?: NativeGovernanceGate | null;
|
|
resolvedTargetAgentId?: string | null;
|
|
};
|
|
|
|
export type NativeAttentionCandidate = {
|
|
route: NativeAttentionFacts["route"];
|
|
summary: string;
|
|
responseState?: NativeAttentionFacts["responseState"];
|
|
targetAgentId?: string | null;
|
|
targetCompanyId?: string | null;
|
|
budgetExhausted?: boolean;
|
|
governanceGate?: NativeGovernanceGate | null;
|
|
targetInteractionId?: string;
|
|
canonicalRequestId?: string;
|
|
};
|
|
|
|
function nonEmptyText(value: unknown) {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function candidateFromPersistedRequest(request: Record<string, unknown>): NativeAttentionCandidate {
|
|
const summary = nonEmptyText(request.summary);
|
|
if (!summary) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_attention_request_invalid",
|
|
"Persisted native attention requires a summary",
|
|
);
|
|
}
|
|
const target = record(request.target);
|
|
const capability = nonEmptyText(request.requestedCapability) ?? nonEmptyText(request.kind);
|
|
const ownerClass = nonEmptyText(target.ownerClass);
|
|
const requiredAuthority = nonEmptyText(request.requiredAuthority);
|
|
const responseState = nonEmptyText(request.responseState);
|
|
const governanceGate = record(request.governanceGate);
|
|
let route: NativeAttentionCandidate["route"];
|
|
if (capability === "context_lookup") route = "context";
|
|
else if (capability === "retry") route = "retry";
|
|
else if (capability === "duplicate") route = "duplicate";
|
|
else if (capability === "alternate_track" || ownerClass === "current_agent") route = "alternate_track";
|
|
else if (capability === "domain_expertise" || ownerClass === "agent") route = "agent";
|
|
else if (
|
|
["review", "credential_grant", "governed_approval", "subjective_decision", "external_action"].includes(capability ?? "")
|
|
|| ["board_user", "external_system"].includes(ownerClass ?? "")
|
|
|| ["board", "external"].includes(requiredAuthority ?? "")
|
|
) route = "human";
|
|
else {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_attention_request_invalid",
|
|
`Persisted native attention capability ${capability ?? "unknown"} has no authorized route`,
|
|
);
|
|
}
|
|
return {
|
|
route,
|
|
summary,
|
|
responseState: ["none", "resolved", "stale", "expired"].includes(responseState ?? "")
|
|
? responseState as NativeAttentionFacts["responseState"]
|
|
: undefined,
|
|
targetAgentId: nonEmptyText(target.agentId),
|
|
targetCompanyId: nonEmptyText(target.companyId),
|
|
budgetExhausted: request.budgetExhausted === true,
|
|
governanceGate: governanceGate.kind === "interaction" && nonEmptyText(governanceGate.id)
|
|
? { kind: "interaction", id: nonEmptyText(governanceGate.id)! }
|
|
: null,
|
|
targetInteractionId: nonEmptyText(request.targetInteractionId) ?? undefined,
|
|
canonicalRequestId: nonEmptyText(request.canonicalRequestId) ?? undefined,
|
|
};
|
|
}
|
|
|
|
export type PersistedNativeAttentionReceipt = {
|
|
requestId: string;
|
|
route: NativeAttentionCandidate["route"];
|
|
assessmentId: string;
|
|
decisionId: string | null;
|
|
reasonCode: string | null;
|
|
resolvedTargetAgentId: string | null;
|
|
materializedTargets: NativeAttentionMaterializedTarget[];
|
|
replayed: boolean;
|
|
};
|
|
|
|
/**
|
|
* Runtime-reachable ingress for accepted attention facts. It starts from the
|
|
* immutable package result row, derives an authorized route, appends one
|
|
* assessment/decision lineage per request, and lets the existing issue and
|
|
* interaction owners materialize the effect.
|
|
*/
|
|
export async function routePersistedNativeResultAttention(input: {
|
|
db: Db;
|
|
runId: string;
|
|
}): Promise<PersistedNativeAttentionReceipt[]> {
|
|
const binding = await input.db.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
resultId: nativeRunFinalizations.resultId,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
}).from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.where(eq(heartbeatRuns.id, input.runId))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!binding || binding.runtimeMode !== "native" || !binding.resultId) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_attention_binding_mismatch",
|
|
"Accepted native result binding not found",
|
|
);
|
|
}
|
|
const resultRow = await input.db.select().from(nativeRunResults).where(and(
|
|
eq(nativeRunResults.id, binding.resultId),
|
|
eq(nativeRunResults.companyId, binding.companyId),
|
|
eq(nativeRunResults.issueId, binding.issueId),
|
|
eq(nativeRunResults.runId, input.runId),
|
|
eq(nativeRunResults.schemaStatus, "accepted"),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (!resultRow) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_attention_result_missing",
|
|
"Accepted native result row not found",
|
|
);
|
|
}
|
|
const result = record(record(resultRow.resultJson).result);
|
|
const requests = Array.isArray(result.attentionRequests)
|
|
? result.attentionRequests.map(record)
|
|
: [];
|
|
const receipts: PersistedNativeAttentionReceipt[] = [];
|
|
|
|
for (const [index, request] of requests.entries()) {
|
|
const candidate = candidateFromPersistedRequest(request);
|
|
const requestId = nonEmptyText(request.id) ?? nativeSha256({
|
|
runId: input.runId,
|
|
resultId: resultRow.id,
|
|
index,
|
|
request,
|
|
});
|
|
const existingAssessment = await input.db.select({ id: workAssessments.id })
|
|
.from(workAssessments).where(and(
|
|
eq(workAssessments.companyId, binding.companyId),
|
|
eq(workAssessments.issueId, binding.issueId),
|
|
eq(workAssessments.runId, input.runId),
|
|
eq(workAssessments.triggerKind, "native_attention"),
|
|
eq(workAssessments.triggerRef, requestId),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (existingAssessment) {
|
|
const existingDecision = await input.db.select({
|
|
id: statusDecisions.id,
|
|
reasonCode: statusDecisions.reasonCode,
|
|
decisionJson: statusDecisions.decisionJson,
|
|
}).from(statusDecisions).where(and(
|
|
eq(statusDecisions.companyId, binding.companyId),
|
|
eq(statusDecisions.assessmentId, existingAssessment.id),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (existingDecision) {
|
|
const decisionJson = record(existingDecision.decisionJson);
|
|
const effects = Array.isArray(decisionJson.effects) ? decisionJson.effects.map(record) : [];
|
|
const delegation = effects.find((effect) => effect.kind === "create_delegated_issue");
|
|
const finalizationError = effects.find((effect) => effect.kind === "record_finalization_error");
|
|
await input.db.update(nativeRunFinalizations).set({
|
|
phase: finalizationError ? "retryable_failure" : "committed",
|
|
assessmentId: existingAssessment.id,
|
|
decisionId: existingDecision.id,
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
recoveryState: null,
|
|
recoveryRequestId: null,
|
|
failureCode: nonEmptyText(finalizationError?.cause),
|
|
failureDetail: finalizationError ? {
|
|
originalFailureCode: finalizationError.cause,
|
|
nextAction: finalizationError.nextAction,
|
|
} : null,
|
|
nextAttemptAt: finalizationError ? new Date(Date.now() + 30_000) : null,
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(nativeRunFinalizations.companyId, binding.companyId),
|
|
eq(nativeRunFinalizations.issueId, binding.issueId),
|
|
));
|
|
receipts.push({
|
|
requestId,
|
|
route: candidate.route,
|
|
assessmentId: existingAssessment.id,
|
|
decisionId: existingDecision.id,
|
|
reasonCode: existingDecision.reasonCode,
|
|
resolvedTargetAgentId: nonEmptyText(delegation?.agentId),
|
|
materializedTargets: [],
|
|
replayed: true,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const issue = await input.db.select({
|
|
status: issues.status,
|
|
statusVersion: issues.statusVersion,
|
|
lastStatusDecisionId: issues.lastStatusDecisionId,
|
|
}).from(issues).where(and(
|
|
eq(issues.id, binding.issueId),
|
|
eq(issues.companyId, binding.companyId),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (!issue) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Issue binding not found");
|
|
const coordinator = await input.db.select({
|
|
phase: nativeRunFinalizations.phase,
|
|
assessmentId: nativeRunFinalizations.assessmentId,
|
|
decisionId: nativeRunFinalizations.decisionId,
|
|
}).from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, input.runId))
|
|
.limit(1).then((rows) => rows[0] ?? null);
|
|
if (!coordinator) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Coordinator binding not found");
|
|
const assessment = await recordNativeAttentionAssessment({
|
|
db: input.db,
|
|
companyId: binding.companyId,
|
|
issueId: binding.issueId,
|
|
runId: input.runId,
|
|
turnId: resultRow.turnId,
|
|
contractId: resultRow.completionContractId,
|
|
resultId: resultRow.id,
|
|
requestId,
|
|
request,
|
|
routingFacts: candidate as unknown as Record<string, unknown>,
|
|
priorIssueStatus: issue.status,
|
|
priorStatusVersion: Number(issue.statusVersion),
|
|
priorDecisionId: issue.lastStatusDecisionId,
|
|
supersedesAssessmentId: coordinator.assessmentId,
|
|
});
|
|
const routed = await routeNativeAttention({
|
|
db: input.db,
|
|
runId: input.runId,
|
|
assessmentId: assessment.id,
|
|
supersedesCommittedDecisionId: coordinator.phase === "committed"
|
|
? coordinator.decisionId ?? undefined
|
|
: undefined,
|
|
candidate,
|
|
});
|
|
receipts.push({
|
|
requestId,
|
|
route: candidate.route,
|
|
assessmentId: assessment.id,
|
|
decisionId: routed.decisionId,
|
|
reasonCode: routed.decision.reasonCode,
|
|
resolvedTargetAgentId: routed.resolvedTargetAgentId,
|
|
materializedTargets: routed.targets,
|
|
replayed: existingAssessment !== null,
|
|
});
|
|
}
|
|
|
|
if (receipts.length > 0) {
|
|
await input.db.transaction(async (tx) => {
|
|
const run = await tx.select({ resultJson: heartbeatRuns.resultJson })
|
|
.from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, binding.companyId),
|
|
)).for("update").limit(1).then((rows) => rows[0] ?? null);
|
|
if (!run) throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Heartbeat run missing");
|
|
await tx.update(heartbeatRuns).set({
|
|
resultJson: { ...record(run.resultJson), nativeAttentionRouting: receipts },
|
|
updatedAt: new Date(),
|
|
}).where(eq(heartbeatRuns.id, input.runId));
|
|
});
|
|
}
|
|
return receipts;
|
|
}
|
|
|
|
/**
|
|
* Internal owner-routing step for native attention. The persisted-result
|
|
* ingress above supplies the server-owned assessment and untrusted candidate;
|
|
* this step resolves company scope and commits or audit-materializes the
|
|
* authority decision.
|
|
*/
|
|
export async function routeNativeAttention(input: {
|
|
db: Db;
|
|
runId: string;
|
|
assessmentId?: string;
|
|
supersedesCommittedDecisionId?: string;
|
|
candidate: NativeAttentionCandidate;
|
|
}) {
|
|
const binding = await input.db.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
assessmentId: nativeRunFinalizations.assessmentId,
|
|
issueStatus: issues.status,
|
|
issueStatusVersion: issues.statusVersion,
|
|
issueDecisionId: issues.lastStatusDecisionId,
|
|
}).from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.innerJoin(issues, and(
|
|
eq(issues.id, nativeRunFinalizations.issueId),
|
|
eq(issues.companyId, heartbeatRuns.companyId),
|
|
))
|
|
.where(eq(heartbeatRuns.id, input.runId))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!binding || binding.runtimeMode !== "native") {
|
|
throw new NativeInteractionBridgeError("native_attention_binding_mismatch", "Native attention run binding not found");
|
|
}
|
|
|
|
const companyAgents = await input.db.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
name: agents.name,
|
|
status: agents.status,
|
|
reportsTo: agents.reportsTo,
|
|
}).from(agents).where(eq(agents.companyId, binding.companyId)).orderBy(asc(agents.createdAt), asc(agents.id));
|
|
const targetSuggestion = input.candidate.targetAgentId
|
|
? await input.db.select({ id: agents.id, companyId: agents.companyId })
|
|
.from(agents).where(eq(agents.id, input.candidate.targetAgentId)).limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const companyScopeValid = (
|
|
!input.candidate.targetCompanyId
|
|
|| input.candidate.targetCompanyId === binding.companyId
|
|
) && (
|
|
!input.candidate.targetAgentId
|
|
|| targetSuggestion?.companyId === binding.companyId
|
|
);
|
|
let resolvedTargetAgentId: string | null = null;
|
|
if (companyScopeValid && input.candidate.route === "agent") {
|
|
const eligible = companyAgents.filter((candidate) =>
|
|
candidate.id !== binding.agentId
|
|
&& getAgentWorkEligibility({ agent: candidate, agents: companyAgents }).invokable
|
|
);
|
|
const requested = eligible.find((candidate) => candidate.id === input.candidate.targetAgentId);
|
|
resolvedTargetAgentId = requested?.id ?? eligible[0]?.id ?? null;
|
|
}
|
|
|
|
const facts: NativeAttentionFacts = {
|
|
companyScopeValid,
|
|
responseState: input.candidate.responseState ?? "none",
|
|
route: input.candidate.route === "agent" && !resolvedTargetAgentId
|
|
? "recovery"
|
|
: input.candidate.route,
|
|
summary: input.candidate.summary,
|
|
budgetExhausted: input.candidate.budgetExhausted,
|
|
governanceGate: input.candidate.governanceGate,
|
|
resolvedTargetAgentId,
|
|
};
|
|
const decision = resolveNativeAttentionStatus({
|
|
facts,
|
|
priorIssueStatus: binding.issueStatus as NativeAuthoritativeIssueStatus,
|
|
agentId: binding.agentId,
|
|
});
|
|
const auditOnly = decision.reasonCode === "attention_duplicate_suppressed";
|
|
if (auditOnly) {
|
|
const targets = await applyNativeAttentionStatusDecision({
|
|
db: input.db,
|
|
companyId: binding.companyId,
|
|
issueId: binding.issueId,
|
|
runId: input.runId,
|
|
decision,
|
|
targetInteractionId: input.candidate.targetInteractionId,
|
|
canonicalRequestId: input.candidate.canonicalRequestId,
|
|
});
|
|
return { decision, decisionId: null, targets, resolvedTargetAgentId };
|
|
}
|
|
const assessmentId = input.assessmentId ?? binding.assessmentId;
|
|
if (!assessmentId) {
|
|
throw new NativeInteractionBridgeError(
|
|
"native_attention_assessment_missing",
|
|
"Native attention requires the server-owned work assessment for this result",
|
|
);
|
|
}
|
|
const committed = await commitNativeStatusDecision({
|
|
db: input.db,
|
|
companyId: binding.companyId,
|
|
issueId: binding.issueId,
|
|
runId: input.runId,
|
|
assessmentId,
|
|
priorStatus: binding.issueStatus,
|
|
priorStatusVersion: Number(binding.issueStatusVersion),
|
|
priorDecisionId: binding.issueDecisionId,
|
|
decision,
|
|
supersedesCommittedDecisionId: input.supersedesCommittedDecisionId,
|
|
});
|
|
return {
|
|
decision,
|
|
decisionId: committed.decision.id,
|
|
targets: [],
|
|
resolvedTargetAgentId,
|
|
};
|
|
}
|
|
|
|
/** Server-owned attention resolution from canonical routing facts. */
|
|
export function resolveNativeAttentionStatus(input: {
|
|
facts: NativeAttentionFacts;
|
|
priorIssueStatus: NativeAuthoritativeIssueStatus;
|
|
agentId: string;
|
|
}): NativeStatusDecision {
|
|
const preserve = (reasonCode: string, effects: NativeStatusEffect[]): NativeStatusDecision => ({
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "preserve",
|
|
toStatus: input.priorIssueStatus,
|
|
reasonCode,
|
|
unblockDescriptor: null,
|
|
effects,
|
|
});
|
|
const continuation = (reasonCode: string): NativeStatusDecision => ({
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_progress",
|
|
toStatus: "in_progress",
|
|
reasonCode,
|
|
unblockDescriptor: null,
|
|
effects: [{
|
|
kind: "enqueue_continuation",
|
|
continuationKind: input.facts.route === "agent" ? "response_wake" : "same_agent",
|
|
summary: input.facts.summary,
|
|
idempotencyKey: `native-attention:${input.facts.route}:${input.facts.summary}`,
|
|
agentId: input.agentId,
|
|
}],
|
|
});
|
|
|
|
if (!input.facts.companyScopeValid) {
|
|
return preserve("result_schema_rejected", [{
|
|
kind: "record_finalization_error",
|
|
cause: "result_schema_rejected",
|
|
nextAction: "Remove the cross-company attention target.",
|
|
agentId: input.agentId,
|
|
}]);
|
|
}
|
|
if (input.facts.responseState === "stale") {
|
|
return preserve("attention_duplicate_suppressed", [{ kind: "record_stale_response" }]);
|
|
}
|
|
if (input.facts.responseState === "expired") {
|
|
return preserve("attention_budget_exhausted", [{ kind: "record_expiry" }]);
|
|
}
|
|
if (input.facts.budgetExhausted || input.facts.route === "recovery") {
|
|
return preserve("attention_budget_exhausted", [{
|
|
kind: "record_finalization_error",
|
|
cause: "attention_budget_exhausted",
|
|
nextAction: "Assign a named recovery owner.",
|
|
agentId: input.agentId,
|
|
}]);
|
|
}
|
|
if (input.facts.route === "duplicate") {
|
|
return preserve("attention_duplicate_suppressed", [{ kind: "link_canonical_request" }]);
|
|
}
|
|
if (input.facts.governanceGate) {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_review",
|
|
toStatus: "in_review",
|
|
reasonCode: "governed_gate_pending",
|
|
unblockDescriptor: null,
|
|
effects: [
|
|
{ kind: "create_interaction", gate: input.facts.governanceGate },
|
|
{ kind: "notify_owner", agentId: input.agentId, reason: "governed_gate_pending" },
|
|
],
|
|
};
|
|
}
|
|
if (input.facts.route === "alternate_track") {
|
|
return continuation("turn_waiting_other_track_live");
|
|
}
|
|
if (input.facts.route === "context" || input.facts.route === "retry") {
|
|
return continuation("attention_resolved_from_context");
|
|
}
|
|
if (input.facts.route === "agent") {
|
|
if (!input.facts.resolvedTargetAgentId) {
|
|
return preserve("attention_budget_exhausted", [{
|
|
kind: "record_finalization_error",
|
|
cause: "attention_no_valid_route",
|
|
nextAction: "Assign a same-company eligible delegate.",
|
|
agentId: input.agentId,
|
|
}]);
|
|
}
|
|
const decision = continuation("attention_routed_to_agent");
|
|
return {
|
|
...decision,
|
|
effects: [
|
|
{ kind: "create_delegated_issue", agentId: input.facts.resolvedTargetAgentId, summary: input.facts.summary },
|
|
...decision.effects,
|
|
],
|
|
};
|
|
}
|
|
if (input.facts.route === "human") {
|
|
return {
|
|
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
|
statusAction: "in_review",
|
|
toStatus: "in_review",
|
|
reasonCode: "attention_requires_human_authority",
|
|
unblockDescriptor: null,
|
|
effects: [
|
|
{ kind: "create_interaction", prompt: input.facts.summary },
|
|
{ kind: "notify_owner", agentId: input.agentId, reason: "attention_requires_human_authority" },
|
|
],
|
|
};
|
|
}
|
|
throw new NativeInteractionBridgeError("native_attention_facts_invalid", "Attention facts do not select a route");
|
|
}
|