mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Conversations must use the same tasks, controls, and execution history. > - Users need an ongoing chat with an agent without managing task properties. > - Agents should clarify and plan work, then hand execution to assigned project tasks. > - This pull request combines the reviewed Agent Chat stack for one squash merge. > - The benefit is persistent conversation with normal task governance and shared UI. ## Linked Issues or Issue Description **Subsystem affected** Task lifecycle, agent runtime tools, shared task UI, and browser/paid runner tests. **Problem or motivation** Users need one persistent conversation with each agent. A separate chat store or renderer would duplicate task behavior and bypass existing controls. **Proposed solution** Use a task-backed chat per company, user, and agent. Reuse the task composer and transcript. Clarify and plan in chat, then create assigned project tasks with the relevant plan. Keep Agent Chat behind its own disabled-by-default experimental setting. **Roadmap alignment** This implements the task-backed direction in [CEO Chat](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md#-ceo-chat). Related proposals: #2504 and #9693. Related request: #7981. The maintainer requested one squash merge of the complete stack. Consolidates the reviewed runtime [#13281](https://github.com/paperclipai/paperclip/pull/13281), backend [#13282](https://github.com/paperclipai/paperclip/pull/13282), and UI [#13283](https://github.com/paperclipai/paperclip/pull/13283) layers with this PR's E2E coverage. All four layers passed CI and received Greptile 5/5 before consolidation. This PR targets master and includes the complete feature. ## What Changed - Add personal canonical chat tasks with ordinary company visibility, immutable identity, idempotent first sends, and an idle waiting state. - Process `/new` in queue order. Preserve history, release a chat pause, and fence old provider context and delayed writes. - Keep chat lifecycle rules across recovery, finalization, assignment, task lists, and rollups. - Support research and plan revision in chat. Hand plans to ordinary assigned project tasks before execution starts. Reject new chat subtasks. - Add repository-aware project creation and discovery tools, including multiple repository IDs and GitHub URLs, authorization, idempotency, and durable project-created cards. - Reuse task UI components for chat, with starred/recent agent navigation and a separate `enableAgentChat` experimental flag. - Add deterministic browser tests and 24 paid chat cells across four Codex/Claude profiles, with validated reports and screenshots. - Integrate current master recovery, controller lease, queued-message, and task UI changes. Gate chat interruption and deferred promotion on ownership/feature policy. Guarantee lease renewal and active controls are stopped even if teardown fails. - Preserve master's migration 0273 and generate chat migration 0274 with idempotent replay for development databases. ## Verification - Prior exact heads of all four PRs passed Linux CI, including build, typecheck, general/serialized tests, and browser E2E. Each had Greptile 5/5 and no unresolved findings. - Integrated local verification passed: full repository typecheck and production build, Storybook build, token gates, 340 focused UI tests, all 20 deterministic chat browser tests, two migration replay tests, 88 focused chat/queue/native/controller tests, and provider/session regressions including real lease expiry. These include the three lifecycle regressions for the final admission/teardown fixes; server typecheck also passes. Current head `1268eda16cc2af892055917e7292f068820be135` has Greptile 5/5 with no unresolved findings and passing security scans. All final-head CI gates passed: build, full Runner verification, typecheck/release registry, canary, all general/serialized test shards, and all browser E2E shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/34696739927)). Local PostgreSQL startup contention required serialized retries; skipped fixtures do not count as passing coverage. - The earlier paid campaign passed all 24 chat cells and retained 32 screenshots: [report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat). It tested `abacbdfd2f660709ec37312cdb758284c8399d04`; it is prior evidence, not a paid run of this integrated head. - Manual check: enable Agent Chat in Experimental settings, open an agent, clarify and revise a plan, then hand off to an assigned project task. Stop a reply, send `/new`, and verify fresh context with retained history. Disable the setting and verify agent shortcuts/new chat turns are blocked. ## Risks - Queue/session integration can affect retries and delayed writes. Tests cover ownership, cancellation, reset boundaries, idle recovery, and ordinary task behavior. - Migration 0274 adds conversation fields and constraints. Replay is idempotent and preserves existing development chat history. - This combines the previously reviewed stack at the maintainer's request. Agent Chat remains off by default and is separate from Conference Room. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, code execution, browser tools, and parallel review. The exact context-window size is not exposed in this session. Codex and Claude also ran as test subjects in the linked paid campaign. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1007 lines
40 KiB
TypeScript
1007 lines
40 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
agentSessionGoalActions,
|
|
agentTaskSessions,
|
|
heartbeatRuns,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
RunnerGoalActionAccepted,
|
|
RunnerGoalActionRequest,
|
|
RunnerGoalCapability,
|
|
RunnerGoalPendingAction,
|
|
RunnerGoalProjection,
|
|
RunnerGoalSnapshot,
|
|
} from "@paperclipai/shared";
|
|
import { queueLiveRunnerPrpCommand } from "../realtime/runner-prp-ws.js";
|
|
import { dispatchLiveRunnerGoalControl } from "./runner-goal-control-broker.js";
|
|
import { publishLiveEvent } from "./live-events.js";
|
|
|
|
const ACTIVE_RUN_STATUSES = ["queued", "scheduled_retry", "running"] as const;
|
|
const OPEN_ACTION_STATUSES = ["pending", "delivering", "delivered"] as const;
|
|
|
|
type AgentBinding = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig">;
|
|
type TaskSession = typeof agentTaskSessions.$inferSelect;
|
|
|
|
export class RunnerGoalConflictError extends Error {
|
|
constructor(
|
|
readonly code: "stale_revision" | "replacement_required" | "idempotency_key_conflict",
|
|
readonly projection: RunnerGoalProjection,
|
|
) {
|
|
super(code);
|
|
}
|
|
}
|
|
|
|
export class RunnerGoalActionError extends Error {
|
|
constructor(readonly code: string, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function capabilityForAgent(agent: AgentBinding): RunnerGoalCapability {
|
|
const config = asRecord(agent.adapterConfig) ?? {};
|
|
const engine = typeof config.engine === "string" ? config.engine : null;
|
|
const sessionMode = typeof config.mode === "string" ? config.mode : "persistent";
|
|
if (agent.adapterType === "paperclip_runner") {
|
|
const provider = typeof config.provider === "string" ? config.provider : "codex";
|
|
const acpxAgent = typeof config.acpxAgent === "string" ? config.acpxAgent : "pi";
|
|
if (provider === "codex" || (provider === "acpx" && acpxAgent === "codex")) {
|
|
return {
|
|
availability: "available",
|
|
verified: false,
|
|
actions: ["set", "pause", "resume", "clear"],
|
|
autonomousUpdates: true,
|
|
persistentAcrossResume: true,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: provider === "codex",
|
|
usageReporting: true,
|
|
reasonCode: "support_pending_handshake",
|
|
reason: provider === "codex"
|
|
? "Support will be verified when the Codex session starts."
|
|
: "Support will be verified when the persistent Codex ACP session starts.",
|
|
};
|
|
}
|
|
if (provider === "acpx" && acpxAgent === "claude") {
|
|
return {
|
|
availability: "available",
|
|
verified: false,
|
|
actions: ["set", "clear"],
|
|
autonomousUpdates: true,
|
|
persistentAcrossResume: true,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: false,
|
|
usageReporting: false,
|
|
reasonCode: "support_pending_handshake",
|
|
reason: "Support will be verified when the persistent Claude ACP session starts.",
|
|
};
|
|
}
|
|
const reason = provider === "opencode"
|
|
? "Unsupported by OpenCode."
|
|
: provider === "acpx"
|
|
? "This ACP agent does not advertise a structured session goal extension."
|
|
: "This Paperclip runner provider does not expose a durable session goal lifecycle.";
|
|
return {
|
|
availability: "unsupported",
|
|
verified: true,
|
|
actions: [],
|
|
autonomousUpdates: false,
|
|
persistentAcrossResume: false,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: false,
|
|
usageReporting: false,
|
|
reasonCode: provider === "opencode"
|
|
? "opencode_structured_goals_unavailable"
|
|
: "persistent_session_goal_extension_required",
|
|
reason,
|
|
};
|
|
}
|
|
const directAcp = (agent.adapterType === "codex_local" || agent.adapterType === "claude_local")
|
|
&& engine !== "cli" && sessionMode !== "oneshot";
|
|
const reason = agent.adapterType === "opencode_local"
|
|
? "Unsupported by OpenCode."
|
|
: directAcp
|
|
? "This direct ACP adapter has no live session goal controller. Use Paperclip Runner with a supported provider."
|
|
: (agent.adapterType === "claude_local" || agent.adapterType === "codex_local") && sessionMode === "oneshot"
|
|
? "Session goals require a persistent ACP session."
|
|
: agent.adapterType === "claude_local"
|
|
? "Requires persistent Claude ACP."
|
|
: agent.adapterType === "codex_local"
|
|
? "Requires persistent Codex ACP."
|
|
: "This runner does not expose a durable session goal lifecycle.";
|
|
return {
|
|
availability: "unsupported",
|
|
verified: true,
|
|
actions: [],
|
|
autonomousUpdates: false,
|
|
persistentAcrossResume: false,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: false,
|
|
usageReporting: false,
|
|
reasonCode: agent.adapterType === "opencode_local"
|
|
? "opencode_structured_goals_unavailable"
|
|
: directAcp ? "direct_adapter_goal_controller_unavailable" : "persistent_session_goal_extension_required",
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function storedCapability(session: TaskSession | null, fallback: RunnerGoalCapability): RunnerGoalCapability {
|
|
// A saved provider capability cannot grant a controller to an execution path
|
|
// that no longer owns it (for example after changing the selected adapter).
|
|
if (fallback.availability === "unsupported") return fallback;
|
|
const stored = asRecord(session?.goalCapabilityJson);
|
|
if (!stored) return fallback;
|
|
const availability = stored.availability;
|
|
const actions = Array.isArray(stored.actions)
|
|
? stored.actions.filter((action): action is "set" | "pause" | "resume" | "clear" =>
|
|
["set", "pause", "resume", "clear"].includes(String(action)))
|
|
: [];
|
|
if (!["available", "unsupported", "policy_disabled"].includes(String(availability))) {
|
|
return fallback;
|
|
}
|
|
return {
|
|
availability: availability as RunnerGoalCapability["availability"],
|
|
verified: true,
|
|
actions,
|
|
autonomousUpdates: stored.autonomousUpdates === true,
|
|
persistentAcrossResume: stored.persistentAcrossResume === true,
|
|
maxObjectiveChars: typeof stored.maxObjectiveChars === "number" ? stored.maxObjectiveChars : 4_000,
|
|
tokenBudgetControl: stored.tokenBudgetControl === true,
|
|
usageReporting: stored.usageReporting === true,
|
|
reasonCode: typeof stored.reasonCode === "string" ? stored.reasonCode : null,
|
|
reason: typeof stored.reason === "string" ? stored.reason : null,
|
|
};
|
|
}
|
|
|
|
function storedGoal(session: TaskSession | null): RunnerGoalSnapshot | null {
|
|
const goal = asRecord(session?.goalJson);
|
|
if (!goal || typeof goal.objective !== "string" || typeof goal.status !== "string") return null;
|
|
return {
|
|
objective: goal.objective,
|
|
status: goal.status as RunnerGoalSnapshot["status"],
|
|
tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
|
|
tokensUsed: typeof goal.tokensUsed === "number" ? goal.tokensUsed : 0,
|
|
elapsedSeconds: typeof goal.elapsedSeconds === "number" ? goal.elapsedSeconds : 0,
|
|
iterations: typeof goal.iterations === "number" ? goal.iterations : 0,
|
|
lastReason: typeof goal.lastReason === "string" ? goal.lastReason : null,
|
|
createdAt: typeof goal.createdAt === "string" && goal.createdAt ? goal.createdAt : null,
|
|
updatedAt: typeof goal.updatedAt === "string" && goal.updatedAt ? goal.updatedAt : null,
|
|
completedAt: typeof goal.completedAt === "string" && goal.completedAt ? goal.completedAt : null,
|
|
workingNow: goal.workingNow === true,
|
|
};
|
|
}
|
|
|
|
function pendingAction(action: string | null): RunnerGoalPendingAction | null {
|
|
switch (action) {
|
|
case "create": return "starting";
|
|
case "edit": return "editing";
|
|
case "replace": return "replacing";
|
|
case "pause": return "pausing";
|
|
case "resume": return "resuming";
|
|
case "clear": return "clearing";
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
function isSameGoalActionRequest(
|
|
storedPayload: unknown,
|
|
request: RunnerGoalActionRequest,
|
|
): boolean {
|
|
const stored = asRecord(storedPayload);
|
|
if (!stored) return false;
|
|
const storedTokenBudget = typeof stored.tokenBudget === "number" || stored.tokenBudget === null
|
|
? stored.tokenBudget
|
|
: undefined;
|
|
return stored.requestId === request.requestId
|
|
&& stored.agentId === request.agentId
|
|
&& stored.expectedRevision === request.expectedRevision
|
|
&& stored.action === request.action
|
|
&& stored.objective === request.objective
|
|
&& storedTokenBudget === request.tokenBudget
|
|
&& (stored.confirmReplace === true) === (request.confirmReplace === true);
|
|
}
|
|
|
|
function commandSequence(request: RunnerGoalActionRequest) {
|
|
if (request.action === "clear") {
|
|
return [{ type: "session.goal.clear", payload: { requestId: request.requestId } }];
|
|
}
|
|
if (request.action === "pause") {
|
|
return [{ type: "session.goal.set", payload: { requestId: request.requestId, status: "paused" } }];
|
|
}
|
|
if (request.action === "resume") {
|
|
return [{ type: "session.goal.set", payload: { requestId: request.requestId, status: "active" } }];
|
|
}
|
|
const setPayload = {
|
|
requestId: request.requestId,
|
|
objective: request.objective,
|
|
...(request.action === "edit" ? {} : { status: "active" }),
|
|
...(request.tokenBudget !== undefined ? { tokenBudget: request.tokenBudget } : {}),
|
|
};
|
|
return request.action === "replace"
|
|
? [
|
|
{ type: "session.goal.clear", payload: { requestId: request.requestId } },
|
|
{ type: "session.goal.set", payload: setPayload },
|
|
]
|
|
: [{ type: "session.goal.set", payload: setPayload }];
|
|
}
|
|
|
|
export function runnerGoalService(
|
|
db: Db,
|
|
options: {
|
|
queueLiveCommand?: typeof queueLiveRunnerPrpCommand;
|
|
dispatchLiveControl?: typeof dispatchLiveRunnerGoalControl;
|
|
enqueueOfflineControl?: (input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
agentId: string;
|
|
requestId: string;
|
|
control: RunnerGoalActionRequest;
|
|
}) => Promise<void>;
|
|
} = {},
|
|
) {
|
|
const queueLiveCommand = options.queueLiveCommand ?? queueLiveRunnerPrpCommand;
|
|
const dispatchLiveControl = options.dispatchLiveControl ?? dispatchLiveRunnerGoalControl;
|
|
|
|
async function readBinding(companyId: string, issueId: string, requestedAgentId?: string | null) {
|
|
const [issue] = await db
|
|
.select({ id: issues.id, companyId: issues.companyId, assigneeAgentId: issues.assigneeAgentId })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
|
.limit(1);
|
|
if (!issue) return null;
|
|
const agentId = requestedAgentId ?? issue.assigneeAgentId;
|
|
if (!agentId) return { issue, agent: null };
|
|
const [agent] = await db
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
adapterType: agents.adapterType,
|
|
adapterConfig: agents.adapterConfig,
|
|
})
|
|
.from(agents)
|
|
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
|
.limit(1);
|
|
return { issue, agent: agent ?? null };
|
|
}
|
|
|
|
async function projection(companyId: string, issueId: string, requestedAgentId?: string | null): Promise<RunnerGoalProjection | null> {
|
|
const binding = await readBinding(companyId, issueId, requestedAgentId);
|
|
if (!binding) return null;
|
|
if (!binding.agent) {
|
|
return {
|
|
issueId,
|
|
agentId: null,
|
|
adapterType: null,
|
|
sessionId: null,
|
|
capability: {
|
|
availability: "unsupported",
|
|
verified: true,
|
|
actions: [],
|
|
autonomousUpdates: false,
|
|
persistentAcrossResume: false,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: false,
|
|
usageReporting: false,
|
|
reasonCode: "no_agent_selected",
|
|
reason: "Select an agent to use session goals.",
|
|
},
|
|
goal: null,
|
|
workingNow: false,
|
|
activeRunId: null,
|
|
pendingAction: null,
|
|
revision: 0,
|
|
observedAt: null,
|
|
};
|
|
}
|
|
const [session, activeRun] = await Promise.all([
|
|
db.select().from(agentTaskSessions).where(and(
|
|
eq(agentTaskSessions.companyId, companyId),
|
|
eq(agentTaskSessions.agentId, binding.agent.id),
|
|
eq(agentTaskSessions.adapterType, binding.agent.adapterType),
|
|
eq(agentTaskSessions.taskKey, issueId),
|
|
)).orderBy(desc(agentTaskSessions.updatedAt)).limit(1).then((rows) => rows[0] ?? null),
|
|
db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, binding.agent.id),
|
|
eq(heartbeatRuns.nativeIssueId, issueId),
|
|
inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]),
|
|
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1).then((rows) => rows[0] ?? null),
|
|
]);
|
|
const pending = session
|
|
? await db.select({ action: agentSessionGoalActions.action })
|
|
.from(agentSessionGoalActions)
|
|
.where(and(
|
|
eq(agentSessionGoalActions.sessionId, session.id),
|
|
inArray(agentSessionGoalActions.status, [...OPEN_ACTION_STATUSES]),
|
|
))
|
|
.orderBy(desc(agentSessionGoalActions.createdAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const goal = storedGoal(session);
|
|
const projectedPendingAction = pendingAction(pending?.action ?? null) ?? (
|
|
goal?.status === "active" && !goal.workingNow && activeRun
|
|
? "continuing"
|
|
: null
|
|
);
|
|
return {
|
|
issueId,
|
|
agentId: binding.agent.id,
|
|
adapterType: binding.agent.adapterType,
|
|
sessionId: session?.id ?? null,
|
|
capability: storedCapability(session, capabilityForAgent(binding.agent)),
|
|
goal,
|
|
workingNow: goal?.workingNow ?? false,
|
|
activeRunId: activeRun?.id ?? null,
|
|
pendingAction: projectedPendingAction,
|
|
revision: session?.goalRevision ?? 0,
|
|
observedAt: session?.goalObservedAt?.toISOString() ?? null,
|
|
};
|
|
}
|
|
|
|
async function act(
|
|
companyId: string,
|
|
issueId: string,
|
|
request: RunnerGoalActionRequest,
|
|
): Promise<RunnerGoalActionAccepted> {
|
|
const binding = await readBinding(companyId, issueId, request.agentId);
|
|
if (!binding) throw new RunnerGoalActionError("issue_not_found", "Issue not found.");
|
|
if (!binding.agent) throw new RunnerGoalActionError("agent_not_found", "Agent not found.");
|
|
if (binding.issue.assigneeAgentId !== request.agentId) {
|
|
throw new RunnerGoalActionError("agent_not_assigned", "The selected agent is not assigned to this issue.");
|
|
}
|
|
const fallbackCapability = capabilityForAgent(binding.agent);
|
|
const initialProjection = await projection(companyId, issueId, request.agentId);
|
|
if (!initialProjection) throw new RunnerGoalActionError("issue_not_found", "Issue not found.");
|
|
if (initialProjection.capability.availability !== "available") {
|
|
throw new RunnerGoalActionError(
|
|
initialProjection.capability.reasonCode ?? "session_goals_unsupported",
|
|
initialProjection.capability.reason ?? "Session goals are unsupported.",
|
|
);
|
|
}
|
|
|
|
const accepted = await db.transaction(async (tx) => {
|
|
await tx.insert(agentTaskSessions).values({
|
|
companyId,
|
|
agentId: request.agentId,
|
|
adapterType: binding.agent!.adapterType,
|
|
taskKey: issueId,
|
|
}).onConflictDoNothing();
|
|
const [session] = await tx.select().from(agentTaskSessions).where(and(
|
|
eq(agentTaskSessions.companyId, companyId),
|
|
eq(agentTaskSessions.agentId, request.agentId),
|
|
eq(agentTaskSessions.adapterType, binding.agent!.adapterType),
|
|
eq(agentTaskSessions.taskKey, issueId),
|
|
)).limit(1).for("update");
|
|
if (!session) throw new RunnerGoalActionError("session_unavailable", "Agent task session is unavailable.");
|
|
|
|
const currentGoal = storedGoal(session);
|
|
const currentProjection: RunnerGoalProjection = {
|
|
...initialProjection,
|
|
sessionId: session.id,
|
|
capability: storedCapability(session, fallbackCapability),
|
|
goal: currentGoal,
|
|
workingNow: currentGoal?.workingNow ?? false,
|
|
revision: session.goalRevision,
|
|
observedAt: session.goalObservedAt?.toISOString() ?? null,
|
|
};
|
|
|
|
const [existingAction] = await tx.select().from(agentSessionGoalActions).where(and(
|
|
eq(agentSessionGoalActions.sessionId, session.id),
|
|
eq(agentSessionGoalActions.requestId, request.requestId),
|
|
)).limit(1);
|
|
if (existingAction) {
|
|
if (!isSameGoalActionRequest(existingAction.payloadJson, request)) {
|
|
throw new RunnerGoalConflictError("idempotency_key_conflict", currentProjection);
|
|
}
|
|
return {
|
|
repeated: true,
|
|
session,
|
|
status: existingAction.status,
|
|
result: asRecord(existingAction.resultJson),
|
|
};
|
|
}
|
|
if (session.goalRevision !== request.expectedRevision) {
|
|
throw new RunnerGoalConflictError("stale_revision", currentProjection);
|
|
}
|
|
const capability = storedCapability(session, fallbackCapability);
|
|
const requiredCapabilityAction = request.action === "pause"
|
|
? "pause"
|
|
: request.action === "resume"
|
|
? "resume"
|
|
: request.action === "clear"
|
|
? "clear"
|
|
: "set";
|
|
if (capability.availability !== "available" || !capability.actions.includes(requiredCapabilityAction)) {
|
|
throw new RunnerGoalActionError(
|
|
capability.reasonCode ?? "goal_action_unsupported",
|
|
capability.reason ?? `${request.action} is unsupported by this agent session.`,
|
|
);
|
|
}
|
|
if (request.tokenBudget != null && !capability.tokenBudgetControl) {
|
|
throw new RunnerGoalActionError(
|
|
"goal_token_budget_unsupported",
|
|
"This agent session does not support goal token budgets.",
|
|
);
|
|
}
|
|
if (request.action === "create" && currentGoal && currentGoal.status !== "complete") {
|
|
throw new RunnerGoalConflictError("replacement_required", currentProjection);
|
|
}
|
|
if (["edit", "replace", "pause", "resume", "clear"].includes(request.action) && !currentGoal) {
|
|
throw new RunnerGoalActionError("goal_not_found", "There is no current session goal.");
|
|
}
|
|
|
|
const nextRevision = session.goalRevision + 1;
|
|
const desiredState = request.action === "pause"
|
|
? "paused"
|
|
: request.action === "clear"
|
|
? null
|
|
: "active";
|
|
await tx.update(agentTaskSessions).set({
|
|
goalDesiredState: desiredState,
|
|
goalRevision: nextRevision,
|
|
updatedAt: new Date(),
|
|
}).where(eq(agentTaskSessions.id, session.id));
|
|
const pendingProjection: RunnerGoalProjection = {
|
|
...currentProjection,
|
|
pendingAction: pendingAction(request.action),
|
|
revision: nextRevision,
|
|
};
|
|
const [action] = await tx.insert(agentSessionGoalActions).values({
|
|
companyId,
|
|
sessionId: session.id,
|
|
requestId: request.requestId,
|
|
action: request.action,
|
|
payloadJson: request,
|
|
resultJson: { projection: pendingProjection },
|
|
}).returning();
|
|
return {
|
|
repeated: false,
|
|
session: { ...session, goalRevision: nextRevision },
|
|
status: action!.status,
|
|
result: { projection: pendingProjection },
|
|
};
|
|
});
|
|
|
|
const originalProjection = accepted.result?.projection as RunnerGoalProjection | undefined;
|
|
if (accepted.repeated) {
|
|
const repeatedStatus = OPEN_ACTION_STATUSES.includes(
|
|
accepted.status as (typeof OPEN_ACTION_STATUSES)[number],
|
|
)
|
|
? "pending"
|
|
: accepted.status === "completed"
|
|
? "completed"
|
|
: "failed";
|
|
return {
|
|
requestId: request.requestId,
|
|
status: repeatedStatus,
|
|
projection: originalProjection ?? (await projection(companyId, issueId, request.agentId))!,
|
|
};
|
|
}
|
|
|
|
const commands = commandSequence(request);
|
|
let live = false;
|
|
const liveCompletions: Array<Promise<unknown>> = [];
|
|
try {
|
|
// Runner transports can retain a durable PRP authority while their
|
|
// heartbeat is suspended. That authority is resumable, but it is not a
|
|
// live command consumer. Only use either in-memory broker when the
|
|
// projection proves a run is currently active; otherwise the outbox
|
|
// resumes the provider session through the goal_control heartbeat.
|
|
const hasActiveRun = typeof originalProjection?.activeRunId === "string";
|
|
const adapterControl = hasActiveRun
|
|
? dispatchLiveControl({
|
|
companyId,
|
|
issueId,
|
|
agentId: request.agentId,
|
|
}, request)
|
|
: null;
|
|
if (adapterControl) {
|
|
live = true;
|
|
liveCompletions.push(adapterControl.completion);
|
|
} else if (hasActiveRun) {
|
|
const queuedCommands = commands.map((command, index) => queueLiveCommand({
|
|
companyId,
|
|
issueId,
|
|
agentId: request.agentId,
|
|
type: command.type,
|
|
payload: command.payload,
|
|
commandId: `goal_${request.requestId}_${index + 1}`,
|
|
}));
|
|
live = queuedCommands.every((queued) => queued !== null);
|
|
for (const queued of queuedCommands) {
|
|
if (queued) liveCompletions.push(queued.completion);
|
|
}
|
|
}
|
|
if (!live && options.enqueueOfflineControl) {
|
|
await options.enqueueOfflineControl({
|
|
companyId,
|
|
issueId,
|
|
agentId: request.agentId,
|
|
requestId: request.requestId,
|
|
control: request,
|
|
});
|
|
}
|
|
await db.update(agentSessionGoalActions).set({
|
|
status: live ? "delivered" : "pending",
|
|
deliveredAt: live ? new Date() : null,
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(agentSessionGoalActions.sessionId, accepted.session.id),
|
|
eq(agentSessionGoalActions.requestId, request.requestId),
|
|
inArray(agentSessionGoalActions.status, ["pending", "delivering"]),
|
|
));
|
|
if (live) {
|
|
void Promise.all(liveCompletions).catch((error) =>
|
|
failRunnerGoalAction(
|
|
db,
|
|
{
|
|
companyId,
|
|
issueId,
|
|
agentId: request.agentId,
|
|
adapterType: binding.agent!.adapterType,
|
|
},
|
|
request.requestId,
|
|
error instanceof Error ? error.message : "live_goal_control_failed",
|
|
)
|
|
).catch(() => undefined);
|
|
}
|
|
} catch (error) {
|
|
await db.update(agentSessionGoalActions).set({
|
|
status: "failed",
|
|
error: error instanceof Error ? error.name : "goal_control_dispatch_failed",
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(agentSessionGoalActions.sessionId, accepted.session.id),
|
|
eq(agentSessionGoalActions.requestId, request.requestId),
|
|
));
|
|
throw error;
|
|
}
|
|
const current = await projection(companyId, issueId, request.agentId);
|
|
if (!current) throw new RunnerGoalActionError("issue_not_found", "Issue not found.");
|
|
publishLiveEvent({
|
|
companyId,
|
|
type: "agent.session.goal.changed",
|
|
payload: current as unknown as Record<string, unknown>,
|
|
});
|
|
return { requestId: request.requestId, status: "accepted", projection: current };
|
|
}
|
|
|
|
return { projection, act };
|
|
}
|
|
|
|
function normalizedPrpGoal(value: unknown, workingNow: boolean): RunnerGoalSnapshot | null {
|
|
const goal = asRecord(value);
|
|
if (!goal || typeof goal.objective !== "string" || typeof goal.status !== "string") return null;
|
|
if (![
|
|
"active",
|
|
"paused",
|
|
"blocked",
|
|
"limited",
|
|
"usage_limited",
|
|
"budget_limited",
|
|
"complete",
|
|
].includes(goal.status)) return null;
|
|
const objective = goal.objective.trim();
|
|
if (!objective || objective.length > 4_000) return null;
|
|
return {
|
|
objective,
|
|
status: goal.status as RunnerGoalSnapshot["status"],
|
|
tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
|
|
tokensUsed: typeof goal.tokensUsed === "number" ? goal.tokensUsed : 0,
|
|
elapsedSeconds: typeof goal.elapsedSeconds === "number" ? goal.elapsedSeconds : 0,
|
|
iterations: typeof goal.iterations === "number" ? goal.iterations : 0,
|
|
lastReason: typeof goal.lastReason === "string" ? goal.lastReason : null,
|
|
createdAt: typeof goal.createdAt === "string" && goal.createdAt ? goal.createdAt : null,
|
|
updatedAt: typeof goal.updatedAt === "string" && goal.updatedAt ? goal.updatedAt : null,
|
|
completedAt: typeof goal.completedAt === "string" && goal.completedAt ? goal.completedAt : null,
|
|
workingNow,
|
|
};
|
|
}
|
|
|
|
/** The durable outbox acknowledgement is authoritative across controller loss. */
|
|
export async function isRunnerGoalActionCompleted(
|
|
db: Db,
|
|
binding: { companyId: string; agentId: string; issueId: string },
|
|
requestId: string,
|
|
): Promise<boolean> {
|
|
const [action] = await db.select({ id: agentSessionGoalActions.id })
|
|
.from(agentSessionGoalActions)
|
|
.innerJoin(agentTaskSessions, eq(agentTaskSessions.id, agentSessionGoalActions.sessionId))
|
|
.where(and(
|
|
eq(agentTaskSessions.companyId, binding.companyId),
|
|
eq(agentTaskSessions.agentId, binding.agentId),
|
|
eq(agentTaskSessions.taskKey, binding.issueId),
|
|
eq(agentSessionGoalActions.requestId, requestId),
|
|
eq(agentSessionGoalActions.status, "completed"),
|
|
)).limit(1);
|
|
return action !== undefined;
|
|
}
|
|
|
|
/** Commits a PRP goal projection with source-epoch and sequence resurrection fences. */
|
|
export async function applyRunnerGoalPrpEvent(
|
|
db: Db,
|
|
binding: {
|
|
companyId: string;
|
|
issueId: string;
|
|
agentId: string;
|
|
adapterType: string;
|
|
},
|
|
event: {
|
|
eventType: string;
|
|
sourceInstanceId?: string;
|
|
sourceRunId?: string;
|
|
sourceSeq: number;
|
|
payload: unknown;
|
|
},
|
|
): Promise<RunnerGoalProjection | null> {
|
|
if (![
|
|
"session.capabilities.updated",
|
|
"session.goal.snapshot",
|
|
"session.goal.updated",
|
|
"session.goal.cleared",
|
|
"turn.started",
|
|
"turn.completed",
|
|
"turn.failed",
|
|
"turn.interrupted",
|
|
"turn.cancelled",
|
|
].includes(event.eventType)) return null;
|
|
const payload = asRecord(event.payload) ?? {};
|
|
const changed = await db.transaction(async (tx) => {
|
|
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId))).for("update");
|
|
if (issue?.conversationAgentId) {
|
|
const [run] = event.sourceRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, event.sourceRunId)) : [];
|
|
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null;
|
|
}
|
|
await tx.insert(agentTaskSessions).values({
|
|
companyId: binding.companyId,
|
|
agentId: binding.agentId,
|
|
adapterType: binding.adapterType,
|
|
taskKey: binding.issueId,
|
|
}).onConflictDoNothing();
|
|
const [session] = await tx.select().from(agentTaskSessions).where(and(
|
|
eq(agentTaskSessions.companyId, binding.companyId),
|
|
eq(agentTaskSessions.agentId, binding.agentId),
|
|
eq(agentTaskSessions.adapterType, binding.adapterType),
|
|
eq(agentTaskSessions.taskKey, binding.issueId),
|
|
)).limit(1).for("update");
|
|
// runnerInstanceId is deliberately stable while a durable provider
|
|
// session is resumed, but each PRP run owns a fresh source sequence. Use
|
|
// the run as the sequence epoch so seq=1 in a successor heartbeat is not
|
|
// mistaken for a replay from its predecessor.
|
|
const sourceId = event.sourceInstanceId
|
|
? event.sourceRunId
|
|
? `${event.sourceInstanceId}:${event.sourceRunId}`
|
|
: event.sourceInstanceId
|
|
: undefined;
|
|
const sameSource = !sourceId || session?.goalSourceId === sourceId;
|
|
if (session?.goalSourceId && sourceId !== session.goalSourceId) {
|
|
// goalSourceId is the persisted run epoch, not just a sequence namespace.
|
|
// Only a running, authenticated successor of the same native session may
|
|
// replace it. A delayed terminal predecessor must never reclaim the epoch
|
|
// or resurrect a clear tombstone, even with a higher source sequence.
|
|
const priorRunId = session.goalSourceId.split(":").at(-1);
|
|
const nextRunId = event.sourceRunId;
|
|
const isUuid = (value: string | undefined): value is string =>
|
|
Boolean(value && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value));
|
|
if (!sourceId || !isUuid(priorRunId) || !isUuid(nextRunId)) return false;
|
|
const owners = await tx.select({
|
|
id: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
nativeSessionId: heartbeatRuns.nativeSessionId,
|
|
runnerInstanceId: heartbeatRuns.runnerInstanceId,
|
|
}).from(heartbeatRuns).where(and(
|
|
inArray(heartbeatRuns.id, [priorRunId, nextRunId]),
|
|
eq(heartbeatRuns.companyId, binding.companyId),
|
|
eq(heartbeatRuns.agentId, binding.agentId),
|
|
eq(heartbeatRuns.nativeIssueId, binding.issueId),
|
|
));
|
|
const prior = owners.find((run) => run.id === priorRunId);
|
|
const next = owners.find((run) => run.id === nextRunId);
|
|
if (
|
|
!prior || !next || next.status !== "running" ||
|
|
next.runnerInstanceId !== event.sourceInstanceId ||
|
|
!next.nativeSessionId || next.nativeSessionId !== prior.nativeSessionId ||
|
|
(prior.id !== next.id && !["succeeded", "failed", "cancelled", "timed_out"].includes(prior.status))
|
|
) return false;
|
|
}
|
|
if (
|
|
!session ||
|
|
(sameSource && session.goalSourceCursor !== null && event.sourceSeq <= session.goalSourceCursor)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const capability = asRecord(payload.sessionGoals);
|
|
const providerError = typeof payload.error === "string" && payload.error.length > 0;
|
|
const turnLifecycleEvent = event.eventType.startsWith("turn.");
|
|
const workingNow = event.eventType === "turn.started"
|
|
? true
|
|
: turnLifecycleEvent
|
|
? false
|
|
: payload.workingNow === true;
|
|
let goal = providerError
|
|
? storedGoal(session)
|
|
: turnLifecycleEvent
|
|
? storedGoal(session)
|
|
: event.eventType === "session.goal.cleared"
|
|
? null
|
|
: normalizedPrpGoal(payload.goal, workingNow);
|
|
if (turnLifecycleEvent && goal) goal = { ...goal, workingNow };
|
|
if (event.eventType === "session.goal.snapshot" && !goal) {
|
|
const persistedGoal = storedGoal(session);
|
|
const alreadyBlockedForMissingProviderGoal =
|
|
persistedGoal?.status === "blocked" &&
|
|
persistedGoal.lastReason === "provider_session_goal_missing_after_resume";
|
|
if (persistedGoal?.status === "complete") {
|
|
// Completed goals remain visible until an explicit clear or
|
|
// replacement. An empty resume snapshot is not a clear event.
|
|
goal = persistedGoal;
|
|
} else if (persistedGoal) {
|
|
goal = {
|
|
...persistedGoal,
|
|
status: "blocked",
|
|
workingNow: false,
|
|
lastReason: "provider_session_goal_missing_after_resume",
|
|
updatedAt: alreadyBlockedForMissingProviderGoal
|
|
? persistedGoal.updatedAt
|
|
: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
let completedPendingActionId: string | null = null;
|
|
if (
|
|
!providerError &&
|
|
(event.eventType === "session.goal.updated" || event.eventType === "session.goal.cleared")
|
|
) {
|
|
const requestId = typeof payload.requestId === "string" ? payload.requestId : null;
|
|
const [pending] = requestId
|
|
? await tx.select({
|
|
id: agentSessionGoalActions.id,
|
|
action: agentSessionGoalActions.action,
|
|
}).from(agentSessionGoalActions).where(and(
|
|
eq(agentSessionGoalActions.sessionId, session.id),
|
|
inArray(agentSessionGoalActions.status, [...OPEN_ACTION_STATUSES]),
|
|
eq(agentSessionGoalActions.requestId, requestId),
|
|
)).limit(1)
|
|
: [];
|
|
const matches = pending && (
|
|
event.eventType === "session.goal.updated"
|
|
? pending.action !== "clear"
|
|
: pending.action === "clear"
|
|
);
|
|
if (pending && matches) completedPendingActionId = pending.id;
|
|
}
|
|
const writesGoal =
|
|
(!providerError && event.eventType.startsWith("session.goal.")) ||
|
|
(turnLifecycleEvent && goal);
|
|
const nextDesiredState = !writesGoal
|
|
? session.goalDesiredState
|
|
: event.eventType === "session.goal.cleared" || goal?.status === "complete"
|
|
? null
|
|
: goal?.status === "active"
|
|
? "active"
|
|
: goal
|
|
? "paused"
|
|
: session.goalDesiredState;
|
|
const projectionChanged =
|
|
(capability !== null && !isDeepStrictEqual(asRecord(session.goalCapabilityJson), capability)) ||
|
|
(Boolean(writesGoal) && !isDeepStrictEqual(storedGoal(session), goal)) ||
|
|
nextDesiredState !== session.goalDesiredState ||
|
|
completedPendingActionId !== null;
|
|
const observedAt = new Date();
|
|
const update: Partial<typeof agentTaskSessions.$inferInsert> = {
|
|
goalSourceCursor: event.sourceSeq,
|
|
...(sourceId ? { goalSourceId: sourceId } : {}),
|
|
goalObservedAt: observedAt,
|
|
updatedAt: observedAt,
|
|
};
|
|
if (!projectionChanged) {
|
|
await tx.update(agentTaskSessions).set(update).where(eq(agentTaskSessions.id, session.id));
|
|
return false;
|
|
}
|
|
update.goalRevision = session.goalRevision + 1;
|
|
if (capability) {
|
|
update.goalCapabilityJson = capability;
|
|
}
|
|
if (writesGoal) {
|
|
update.goalJson = goal as unknown as Record<string, unknown> | null;
|
|
update.goalStatus = goal?.status ?? null;
|
|
// Blocked and limited goals remain resumable, but must not be picked up
|
|
// by the automatic active-goal recovery sweep until a user resumes.
|
|
update.goalDesiredState = nextDesiredState;
|
|
}
|
|
await tx.update(agentTaskSessions).set(update).where(eq(agentTaskSessions.id, session.id));
|
|
|
|
if (completedPendingActionId) {
|
|
await tx.update(agentSessionGoalActions).set({
|
|
status: "completed",
|
|
completedAt: observedAt,
|
|
updatedAt: observedAt,
|
|
}).where(eq(agentSessionGoalActions.id, completedPendingActionId));
|
|
}
|
|
return true;
|
|
});
|
|
if (!changed) return null;
|
|
const current = await runnerGoalService(db).projection(
|
|
binding.companyId,
|
|
binding.issueId,
|
|
binding.agentId,
|
|
);
|
|
if (!current) return null;
|
|
publishLiveEvent({
|
|
companyId: binding.companyId,
|
|
type: "agent.session.goal.changed",
|
|
payload: current as unknown as Record<string, unknown>,
|
|
});
|
|
return current;
|
|
}
|
|
|
|
export async function failRunnerGoalAction(
|
|
db: Db,
|
|
binding: { companyId: string; issueId: string; agentId: string; adapterType: string },
|
|
requestId: string,
|
|
errorCode: string,
|
|
): Promise<RunnerGoalProjection | null> {
|
|
const changed = await db.transaction(async (tx) => {
|
|
const [session] = await tx.select().from(agentTaskSessions).where(and(
|
|
eq(agentTaskSessions.companyId, binding.companyId),
|
|
eq(agentTaskSessions.agentId, binding.agentId),
|
|
eq(agentTaskSessions.adapterType, binding.adapterType),
|
|
eq(agentTaskSessions.taskKey, binding.issueId),
|
|
)).limit(1).for("update");
|
|
if (!session) return false;
|
|
const now = new Date();
|
|
const failed = await tx.update(agentSessionGoalActions).set({
|
|
status: "failed",
|
|
error: errorCode.slice(0, 240),
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(agentSessionGoalActions.sessionId, session.id),
|
|
eq(agentSessionGoalActions.requestId, requestId),
|
|
inArray(agentSessionGoalActions.status, [...OPEN_ACTION_STATUSES]),
|
|
)).returning({ id: agentSessionGoalActions.id });
|
|
if (failed.length === 0) return false;
|
|
// Pending-action transitions are part of the same revisioned projection as
|
|
// provider snapshots. Clients must not discard a failed start as a replay.
|
|
await tx.update(agentTaskSessions).set({
|
|
goalRevision: session.goalRevision + 1,
|
|
goalObservedAt: now,
|
|
updatedAt: now,
|
|
}).where(eq(agentTaskSessions.id, session.id));
|
|
return true;
|
|
});
|
|
if (!changed) return null;
|
|
const current = await runnerGoalService(db).projection(
|
|
binding.companyId,
|
|
binding.issueId,
|
|
binding.agentId,
|
|
);
|
|
if (current) {
|
|
publishLiveEvent({
|
|
companyId: binding.companyId,
|
|
type: "agent.session.goal.changed",
|
|
payload: current as unknown as Record<string, unknown>,
|
|
});
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export async function blockRunnerGoalRecovery(
|
|
db: Db,
|
|
binding: { companyId: string; issueId: string; agentId: string; adapterType: string },
|
|
reason = "provider_session_goal_recovery_failed",
|
|
): Promise<RunnerGoalProjection | null> {
|
|
const changed = await db.transaction(async (tx) => {
|
|
const [session] = await tx.select().from(agentTaskSessions).where(and(
|
|
eq(agentTaskSessions.companyId, binding.companyId),
|
|
eq(agentTaskSessions.agentId, binding.agentId),
|
|
eq(agentTaskSessions.adapterType, binding.adapterType),
|
|
eq(agentTaskSessions.taskKey, binding.issueId),
|
|
)).limit(1).for("update");
|
|
const goal = storedGoal(session ?? null);
|
|
if (!session || !goal || goal.status !== "active") return false;
|
|
const now = new Date();
|
|
await tx.update(agentTaskSessions).set({
|
|
goalJson: {
|
|
...goal,
|
|
status: "blocked",
|
|
workingNow: false,
|
|
lastReason: reason,
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
goalStatus: "blocked",
|
|
goalDesiredState: "paused",
|
|
goalRevision: session.goalRevision + 1,
|
|
goalObservedAt: now,
|
|
updatedAt: now,
|
|
}).where(eq(agentTaskSessions.id, session.id));
|
|
return true;
|
|
});
|
|
if (!changed) return null;
|
|
const current = await runnerGoalService(db).projection(
|
|
binding.companyId,
|
|
binding.issueId,
|
|
binding.agentId,
|
|
);
|
|
if (current) {
|
|
publishLiveEvent({
|
|
companyId: binding.companyId,
|
|
type: "agent.session.goal.changed",
|
|
payload: current as unknown as Record<string, unknown>,
|
|
});
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Stops an unfinished goal through the controller that owns the live provider
|
|
* session. Codex-style sessions pause; providers that only advertise clear
|
|
* (currently Claude ACP) are cleared. The caller must not interrupt the process
|
|
* until this function confirms the resulting projection.
|
|
*/
|
|
export async function settleLiveRunnerGoalBeforeInterrupt(
|
|
db: Db,
|
|
binding: { companyId: string; issueId: string; agentId: string },
|
|
): Promise<"paused" | "cleared" | "none"> {
|
|
const goals = runnerGoalService(db);
|
|
const current = await goals.projection(binding.companyId, binding.issueId, binding.agentId);
|
|
if (!current?.goal || current.goal.status === "complete") return "none";
|
|
if (current.goal.status === "paused") return "paused";
|
|
const action = current.capability.actions.includes("pause")
|
|
? "pause" as const
|
|
: current.capability.actions.includes("clear")
|
|
? "clear" as const
|
|
: null;
|
|
if (!action) {
|
|
throw new RunnerGoalActionError(
|
|
"runner_goal_stop_unsupported",
|
|
"The live agent goal cannot be stopped safely.",
|
|
);
|
|
}
|
|
|
|
const requestId = `interrupt_${randomUUID()}`;
|
|
const adapterControl = dispatchLiveRunnerGoalControl(binding, { requestId, action });
|
|
const nativeControl = adapterControl
|
|
? null
|
|
: queueLiveRunnerPrpCommand({
|
|
...binding,
|
|
type: action === "pause" ? "session.goal.set" : "session.goal.clear",
|
|
payload: action === "pause"
|
|
? { requestId, status: "paused" }
|
|
: { requestId },
|
|
commandId: `goal_${requestId}`,
|
|
});
|
|
const completion = adapterControl?.completion ?? nativeControl?.completion ?? null;
|
|
if (!completion) {
|
|
throw new RunnerGoalActionError(
|
|
"runner_goal_live_controller_unavailable",
|
|
"The live agent goal controller is unavailable; the run was not interrupted.",
|
|
);
|
|
}
|
|
await completion;
|
|
|
|
const deadline = Date.now() + 5_000;
|
|
while (Date.now() < deadline) {
|
|
const observed = await goals.projection(binding.companyId, binding.issueId, binding.agentId);
|
|
if (action === "pause" && observed?.goal?.status === "paused") return "paused";
|
|
if (action === "clear" && observed?.goal === null) return "cleared";
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
throw new RunnerGoalActionError(
|
|
"runner_goal_stop_unconfirmed",
|
|
"The provider did not confirm that the live agent goal stopped.",
|
|
);
|
|
}
|