mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/6/changes/paperclip/6 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip manages AI agents and the tasks that they execute. > - External conversations must become idle after an authorized response is published and all related work is complete. > - Native replay and stranded-task recovery still discover this settlement work through recurring scans. > - A new deadline worker must preserve response ordering, monitor handoffs, and unfinished work. > - This pull request gives settlement one shared readiness predicate and activates its worker from committed changes. > - Existing rows provide recovery truth. No new table or per-feature timer is added. > - The benefit is a settlement worker that becomes quiet and resumes when relevant work changes. ## Linked Issues or Issue Description Refs #17758. This is slice 56. Slice 55 has merged. Related: #17606 (chat milestones), #17663 (bounded run reads), #17665 (monitor proof), and #13809 (answered Slack conversation lifecycle). The provider applicability remains Slack and Speko-owned conversations. This does not extend Telegram settlement from #16559. **What happened?** Conversation settlement depended on periodic native replay and generic task recovery. An exhausted monitor also cleared its schedule before recovery admission. During that gap, an answered conversation could look finished. Failed or deferred admission lost the monitor schedule. **Expected behavior** Relevant committed changes activate settlement. The worker uses the owning service's evidence and becomes quiet when no settlement is owed. Monitor recovery stays pending until admission, and restart does not admit an already accepted recovery twice. **Steps to reproduce** 1. Create an active external conversation with a successful run and an authorized published response. 2. Keep execution, approval, a goal, or a monitor pending. Let the settlement worker become quiet. 3. Resolve that prerequisite through its owning service. Verify that the conversation settles without another periodic sweep. 4. Delay or fail exhausted-monitor recovery admission. Verify that the monitor remains pending and the conversation cannot settle during the handoff. **Paperclip version or commit** Accepted production source: `50f4432ed88537b8d522cf1dce24c33588db0bac`. Parent #17758 merged as `8ac5a128e66fb672e88106412f76cf278599bfc4`. The publication adopts that actual squash through a normal merge with an unchanged complete child tree. ## What Changed - Preserve quiet after coarse company-only maintenance hints. The shared inbound-notice and response-availability helpers keep settlement enabled by default. Only the milestone dispatcher suppresses their duplicate settlement fanout. Its scoped issue-review signal and the explicit company-authority signal still wake real work. Four database cases prove both quiet and real company-wide producer delivery. - Preserve source-generation checks for chat handoff, task-before-comment locking, issue-scoped decision invalidation, and current conversation event columns from master. - Track initial external conversation settlement through shutdown without blocking HTTP readiness on endpoint or task locks. - Reuse the real queue's coalesced recovery receipt after restart, without admitting the same monitor recovery again. - Share the existing conversation readiness predicate between bounded discovery and locked settlement. Preserve current provider policy, run/publication proof, company boundaries, and endpoint-to-issue lock order. - Register a scoped deadline worker through the delivery coordinator. Preserve startup, restore/worktree suppression, drain/resume, failure recovery, and joined shutdown. - Extend existing review, milestone, wake-notice, and finalization signals. Add notifications at independent issue, comment, goal, session, and wake-receipt unblocks. - Keep monitor owner recovery pending until guarded admission. Reuse an accepted receipt for the same monitor schedule after restart, even when its limit reason changes; retain compatibility with legacy receipt keys. Commit board escalation and its monitor clear together. - Delegate periodic native settlement while preserving resource callbacks. Keep direct/manual defaults and preserve generic recovery ordering with a read-only readiness guard. - Add the producer and lifecycle audit in `doc/plans/2026-10-26-external-conversation-settlement-sleep.md` and update the stack ledger. ## Verification Accepted production source: `50f4432ed88537b8d522cf1dce24c33588db0bac`. Publication head: `b256bb52647affd9f5279ea8c40f48bbe40f031f`. The complete accepted child tree stays unchanged when adopting merged parent #17758. This publication adds only the audit document. - **3,129 distinct cases in 37 complete server files pass**, plus **18 selected chat-response cases** (3,147 selected cases). The other 1,796 chat cases were not run locally. Full settlement, queue, scheduling, run-state, goals, interactions, task Retry, decision targets, status reassessment, workspace, process recovery, and review consumers are covered. - **Preserved failure:** the first 35-file command passed 2,445 cases and timed out one restored-work handoff at its existing 15-second limit. That aggregate command failed. The unchanged full 89-case actions file then passed on a fresh fixture; the affected case took 22 ms. Root cause was not established. No timeout or assertion changed. The 34 other files passed originally. Distinct counts exclude duplicate actions passes. Fresh CI remains a separate gate. - **Indirect wake regression:** the original unconditional baseline failed two quiet checks. Guards alone left the chat case failing with three candidate queries. The final controlled indirect baseline fails one of four cases; the repair passes all four with the same test hash. Positive controls preserve real company-only producers. - **Source and fixture controls:** all 38 selected test files restore to their exact committed hashes after fixture imports. Fresh PostgreSQL 17 fixtures use 413 migrations. All five manual merge resolutions preserve current authority, locking, and event-column contracts. - **Static checks pass:** database build; server typecheck and build; module boundaries; migration order; Copilot profile identity. Runner packages equal the accepted parent byte for byte. No new Runner/package qualification is claimed. - [Current integration and wake audit](https://github.com/paperclipai/paperclip/blob/b256bb52647affd9f5279ea8c40f48bbe40f031f/doc/plans/2026-10-26-external-conversation-settlement-sleep.md#current-master-integration-and-indirect-wake-audit-2026-10-29). Latest-head CI and exact-head Greptile 5/5 must pass before merge. <details><summary>Earlier revision verification and preserved failure history</summary> Receiver failure fairness: a controlled 1,100 ms batch reproduced a 30-second delay for healthy due receivers. The worker now supplies its existing per-item deadline through `nextRunAtOnError`. All 20 receiver lifecycle tests pass on the owner and combined tip `31c12f1d68ff4a51e06fc2be2abf2c2aee9f9d3a`; server production typechecking passes on the owner. The failed receiver retains its own backoff, and settled receivers return to zero recurring SQL. The five-second assertion budget is unchanged. See [the owning audit](https://github.com/paperclipai/paperclip/blob/83204a67880d980cfbea3771cee19a3cda0022b7/doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md#healthy-receiver-progress-after-a-slow-failed-batch). Current-head CI and review remain required. Approved delegated Test-tab calls now evaluate caller and target policies in the existing claim transaction. Real HTTP regressions first showed a missing caller charge and shared-limit self-rejection. All 78 connection-management cases and 92 gateway cases now pass on the owning branch, along with server production typechecking. All 78 connection-management cases also pass at combined tip `d25f830db89c1623bff3547bfc68015c1c66e0cf`. The three new cases cover shared charges, separate charges, rollback on denial, duplicate approval, and rejection of a later authorized call. Intermediate counter/identity fixture failures and a reused-database Slack collision are recorded in [the owning audit](https://github.com/paperclipai/paperclip/blob/ef9ae5767430d0093efc736ae0b303ba5afaae65/doc/idle-approved-tool-recovery.md#delegated-test-tab-admission-after-policy-changes). The earlier combined checks below remain evidence for their stated revisions. Current-head CI and review remain required. Inherited voice-cleanup verification: the controlled slow-provider regression and all 13 lifecycle tests passed on its owner and combined tip `ab22d8ed0c09028315607414b83b375e5cc0a60a`. That repair changed only the fixture clock advancement and its audit note. Current head: `83204a67880d980cfbea3771cee19a3cda0022b7`. This remains a single-duty PR stacked on #17758. - Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5` with normal merges. Every branch passed generated-schema checks and its target-branch migration-order gate. Existing SQL is unchanged; the 25 stack migrations follow master’s new migration. - Duty-specific producer coverage, lifecycle tests, and earlier failure records: [2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/d6d8e4ffa6b330ebf4647aec997ee2dde6abc321/doc/plans/2026-10-24-recurring-server-sleep-stack.md), [2026-10-26-external-conversation-settlement-sleep.md](https://github.com/paperclipai/paperclip/blob/d6d8e4ffa6b330ebf4647aec997ee2dde6abc321/doc/plans/2026-10-26-external-conversation-settlement-sleep.md). - Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355 startup/cancellation/recovery/dispatch/preservation tests**, **75 connection-management tests**, and production server typechecking pass. These combined checks do not mean every intermediate PR independently ran the full repository suite. - Real master-to-stack database upgrade passes: **379 unchanged prior migration receipts plus 25 new migrations**, with the gateway responsibility fixture preserved. - Owning-branch checks: 84 runner installation/settlement cases plus runner typechecking and generated TypeScript/Rust profile agreement; 253 GitHub review workflow cases. Stop-attribution and held-worker readiness regressions failed before their fixes. - Failure accounting: the initial combined run passed 349 cases and failed six idle assertions in a reused preservation database containing old cancelled runs. Repeating the combined command with a fresh preservation database passed. Earlier invalid project selection, merge import/type errors, and the interrupted full local suite are not counted as passing checks. - **Latest-head CI and review remain required.** Prior CI findings include GitHub idle observation, route teardown, attention cleanup, touch-picker E2E, workspace health, and voice-cleanup timing; local passes do not erase those failures. No full local repository-suite, deployed sleep, fleet cost, or live Copilot qualification claim is made. </details> ## Risks - Signals cover supported in-process writers. They do not supply change capture for independent SQL or other replicas. - Real company-wide changes can inspect active candidates. Coarse maintenance hints do not activate settlement. Configured idle and retained history must remain quiet. - Monitor receipt binding and admission order must preserve company, agent, issue, and exact schedule identity. Current parent handoff locks and source-generation checks remain intact. - The local restored-work timeout is retained above. A fresh full-file pass does not establish its cause; latest-head CI must also pass. - Generic recovery and Muse/resource duties remain separate slices. This does not make the whole server idle. Existing Cloud sleep blockers remain. No deployed sleep, database suspension, external alarm coverage, fleet cost, or live provider qualification claim is made. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, PostgreSQL tests, and GitHub tools. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
1009 lines
40 KiB
TypeScript
1009 lines
40 KiB
TypeScript
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
|
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;
|
|
if (session.goalStatus !== (goal?.status ?? null)) await notifyExternalConversationSettlement(tx, binding);
|
|
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.",
|
|
);
|
|
}
|