mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
fix(native-runner): materialize phase 6 live effects
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
33d9e8ef66
commit
3bed55f3f7
14 files changed
+1920
-478
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { NativeInteractionResponseEnvelope } from "@paperclipai/paperclip-runner";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import {
|
||||
arbitrateNativeStatusScenario,
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
type NativeGovernanceGate,
|
||||
type NativeStatusAuthorityScenario,
|
||||
type NativeStatusDecision,
|
||||
type NativeStatusEffect,
|
||||
} from "./status-arbiter.js";
|
||||
|
||||
export class NativeInteractionBridgeError extends Error {
|
||||
@@ -15,6 +18,108 @@ export class NativeInteractionBridgeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
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: "issue_thread_interaction" | "native_run_finalization";
|
||||
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 })
|
||||
.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");
|
||||
}
|
||||
await tx.update(issueThreadInteractions).set({
|
||||
summary: `Canonical native attention request: ${canonical.id}`,
|
||||
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 })
|
||||
.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");
|
||||
await tx.update(issueThreadInteractions).set({
|
||||
summary: "Response retained for audit after native supersession.",
|
||||
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({
|
||||
status: "failed",
|
||||
finishedAt: new Date(),
|
||||
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
|
||||
@@ -30,7 +135,14 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
}): Promise<NativeInteractionResponseEnvelope[]> {
|
||||
const requestedIds = new Set(input.interactionIds.filter(Boolean));
|
||||
if (requestedIds.size === 0) return [];
|
||||
const interactions = await issueThreadInteractionService(input.db).listForIssue(input.issueId);
|
||||
const [interactions, issue] = await Promise.all([
|
||||
issueThreadInteractionService(input.db).listForIssue(input.issueId),
|
||||
input.db.select({ status: issues.status }).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) {
|
||||
@@ -41,6 +153,41 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
`Interaction ${interaction.id} is not bound to the native company and issue`,
|
||||
);
|
||||
}
|
||||
const interactionResult = record(interaction.result);
|
||||
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
|
||||
|| (
|
||||
@@ -83,6 +230,19 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
`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,
|
||||
@@ -101,6 +261,19 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
`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");
|
||||
}
|
||||
responses.push({
|
||||
interactionId: interaction.id,
|
||||
kind: interaction.kind,
|
||||
@@ -139,23 +312,110 @@ export function rejectUnsupportedNativeRuntimeRequest(requestKind: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const ATTENTION_STATUS_SCENARIOS = new Set<NativeStatusAuthorityScenario>([
|
||||
"alternate_track_runnable", "context_answer_current", "ordinary_domain_expertise",
|
||||
"intentional_human_judgment", "equivalent_attention_family", "resolver_budget_exhausted",
|
||||
"transient_retry_then_success", "cross_company_target", "response_after_supersession",
|
||||
"interaction_expired", "governed_gate_pending",
|
||||
]);
|
||||
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;
|
||||
};
|
||||
|
||||
/** Server-owned attention resolution; candidates never author status/effects. */
|
||||
/** Server-owned attention resolution from canonical routing facts. */
|
||||
export function resolveNativeAttentionStatus(input: {
|
||||
scenario: NativeStatusAuthorityScenario;
|
||||
facts: NativeAttentionFacts;
|
||||
priorIssueStatus: NativeAuthoritativeIssueStatus;
|
||||
agentId: string;
|
||||
governanceGate?: NativeGovernanceGate | null;
|
||||
blockerAction?: string;
|
||||
}) {
|
||||
if (!ATTENTION_STATUS_SCENARIOS.has(input.scenario)) {
|
||||
throw new NativeInteractionBridgeError("native_attention_scenario_invalid", input.scenario);
|
||||
}): 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,
|
||||
}]);
|
||||
}
|
||||
return arbitrateNativeStatusScenario(input);
|
||||
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") {
|
||||
const decision = continuation("attention_routed_to_agent");
|
||||
return {
|
||||
...decision,
|
||||
effects: [
|
||||
{ kind: "create_delegated_issue", agentId: input.agentId, 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");
|
||||
}
|
||||
Reference in new issue
Block a user