Add end-to-end session goals to Paperclip Runner

Add capability-aware slash-goal controls, durable provider goal state, PRP v2 negotiation, autonomous goal execution, and safe local session recovery. Integrate with current master, preserve provider session identity, and verify the browser goal/chat/replacement/clear workflow and unsupported-agent rejection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 16:18:47 -05:00 committed by GitHub
parent 6019e2bd6e
commit 7ed122911b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
133 changed files with 54513 additions and 389 deletions

View File

@ -247,6 +247,15 @@ finalization ledger, whose retry time and owner lease are checked under a row
lock. None of these writes selects a runtime or changes a legacy run's execution
path.
Durable agent session goals are an additive projection on
`agent_task_sessions`, distinct from the business-goal hierarchy. The row stores
the negotiated goal capability, normalized snapshot and status, desired state,
provider source cursor, monotonic projection revision, and observation time.
`agent_session_goal_actions` is the control outbox: `(session_id, request_id)`
is unique, so retries return the original accepted action. Provider source
ordering fences duplicate and stale updates, and a cleared projection retains
its revision/cursor tombstone so an older provider event cannot resurrect it.
Issue `status_version` advances only when `status` changes. The JavaScript backup
path includes user-defined functions and triggers so a restored database keeps
that invariant. Removing or disabling a future native rollout flag must not

View File

@ -858,6 +858,14 @@ that classification finishes.
signal it or spawn a replacement.
- A persisted proposed or terminal result is reconciled before any runner or
provider work starts, so restart recovery cannot submit a duplicate turn.
- On the next run, a completed local Codex session whose warm controller died
before suspension is recovered automatically, including a uniquely verified
checkpoint quarantined by older controllers. Paperclip requires matching
database/session/provider identities, a settled terminal journal, no pending
commands or active provider turn, and a confirmed-dead process and process
group. It seals the old authority for normal epoch rotation and preserves the
Codex thread and goal state. Empty retry directories do not prevent recovery;
conflicting histories, changed profiles, and live or unverifiable owners do.
Run the credential-free real-process restart suite with:

View File

@ -1039,6 +1039,17 @@ instances return `404`.
- `GET /issues/:issueId/attachments`
- `GET /attachments/:attachmentId/content`
- `DELETE /attachments/:attachmentId`
- `GET /issues/:issueId/runner-goal?agentId=...`
- `POST /issues/:issueId/runner-goal/actions`
The runner-goal endpoints control an issue-scoped durable agent-session goal,
not a row in the company `goals` hierarchy. Reads return the effective agent,
negotiated capability, normalized goal snapshot, active-run state, pending
action, and revision. Mutations require a request id, assigned agent, expected
revision, and a negotiated action; they return `202`, replay the original result
for a duplicate request id, and return `409` with the current projection for a
stale revision or an unconfirmed unfinished-goal replacement. These controls do
not create issue comments.
### 10.4.1 Atomic Checkout Contract
@ -1233,6 +1244,35 @@ Scheduler must skip invocation when:
- an existing run is active
- hard budget limit has been hit
## 11.7 Durable agent session goals
Runner Protocol v2 negotiates a required `sessionGoals` capability and typed
`session.goal.*` commands and events. PRP v1 sessions remain supported and are
goal unsupported. The Codex app-server driver maps controls to
`thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`; it observes
provider-created goal notifications and reconciles with an authoritative get
after each turn. An active goal suppresses premature run terminalization while
autonomous turns continue. The Paperclip runner's persistent ACP backend opts
in through the `_session/goal` extension and advertises its exact action subset.
Its pinned Codex/Claude executables retain the runner's Linux x64 qualification
requirement. Direct `codex_local` and `claude_local` adapters currently have no
live goal controller and remain unsupported, even when their underlying ACP
package exposes goals. Goal actions never change an agent's adapter, model,
permission policy, or rollout settings to manufacture support. CLI, one-shot
ACP, and providers without the structured extension remain unsupported.
When a goal heartbeat settles, the runner suspends its durable authority even
under a warm lifecycle policy. Paused, blocked, completed, and rollover goals
must survive controller restart without relying on an in-memory warm owner.
The next run resumes the same provider session through the existing verified
checkpoint and authority-rotation path.
The board composer treats `/goal` as an action command rather than Markdown or
comment text. It is capability-aware, and the issue thread renders durable goal
status and controls immediately above the composer. Goal completion enters the
normal run-result/completion arbitration path and does not directly close the
issue.
## 12. Governance and Approval Flows
## 12.1 Hiring

View File

@ -0,0 +1,36 @@
-- Preview checkouts used earlier migration numbers; retain their goal state.
CREATE TABLE IF NOT EXISTS "agent_session_goal_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"session_id" uuid NOT NULL,
"request_id" text NOT NULL,
"action" text NOT NULL,
"payload_json" jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"result_json" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"delivered_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_capability_json" jsonb;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_json" jsonb;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_status" text;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_desired_state" text;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_source_id" text;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_source_cursor" bigint;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "agent_task_sessions" ADD COLUMN IF NOT EXISTS "goal_observed_at" timestamp with time zone;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "agent_session_goal_actions" ADD CONSTRAINT "agent_session_goal_actions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "agent_session_goal_actions" ADD CONSTRAINT "agent_session_goal_actions_session_id_agent_task_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."agent_task_sessions"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "agent_session_goal_actions_session_request_uniq" ON "agent_session_goal_actions" USING btree ("session_id","request_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_session_goal_actions_company_status_created_idx" ON "agent_session_goal_actions" USING btree ("company_id","status","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_session_goal_actions_session_created_idx" ON "agent_session_goal_actions" USING btree ("session_id","created_at");

File diff suppressed because it is too large Load Diff

View File

@ -1723,6 +1723,13 @@
"when": 1788812507973,
"tag": "0247_even_moon_knight",
"breakpoints": true
},
{
"idx": 248,
"version": "7",
"when": 1788901245075,
"tag": "0248_small_manta",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,14 @@
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
integer,
bigint,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
@ -15,6 +25,14 @@ export const agentTaskSessions = pgTable(
sessionDisplayId: text("session_display_id"),
lastRunId: uuid("last_run_id").references(() => heartbeatRuns.id),
lastError: text("last_error"),
goalCapabilityJson: jsonb("goal_capability_json").$type<Record<string, unknown>>(),
goalJson: jsonb("goal_json").$type<Record<string, unknown>>(),
goalStatus: text("goal_status"),
goalDesiredState: text("goal_desired_state"),
goalSourceId: text("goal_source_id"),
goalSourceCursor: bigint("goal_source_cursor", { mode: "number" }),
goalRevision: integer("goal_revision").notNull().default(0),
goalObservedAt: timestamp("goal_observed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
@ -37,3 +55,39 @@ export const agentTaskSessions = pgTable(
),
}),
);
export const agentSessionGoalActions = pgTable(
"agent_session_goal_actions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
sessionId: uuid("session_id")
.notNull()
.references(() => agentTaskSessions.id, { onDelete: "cascade" }),
requestId: text("request_id").notNull(),
action: text("action").notNull(),
payloadJson: jsonb("payload_json").$type<Record<string, unknown>>().notNull(),
status: text("status").notNull().default("pending"),
resultJson: jsonb("result_json").$type<Record<string, unknown>>(),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
sessionRequestUniqueIdx: uniqueIndex("agent_session_goal_actions_session_request_uniq").on(
table.sessionId,
table.requestId,
),
companyStatusCreatedIdx: index("agent_session_goal_actions_company_status_created_idx").on(
table.companyId,
table.status,
table.createdAt,
),
sessionCreatedIdx: index("agent_session_goal_actions_session_created_idx").on(
table.sessionId,
table.createdAt,
),
}),
);

View File

@ -22,7 +22,7 @@ export { budgetIncidents } from "./budget_incidents.js";
export { agentConfigRevisions } from "./agent_config_revisions.js";
export { agentApiKeys } from "./agent_api_keys.js";
export { agentRuntimeState } from "./agent_runtime_state.js";
export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentTaskSessions, agentSessionGoalActions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";

View File

@ -0,0 +1,46 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const migration = readFileSync(new URL("./migrations/0248_small_manta.sql", import.meta.url), "utf8");
(support.supported ? describe : describe.skip)("session goal migration", () => {
it("preserves preview goals, tombstone revisions, and pending actions on replay", async () => {
const database = await startEmbeddedPostgresTestDatabase("goal-migration-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const companyId = randomUUID(), agentId = randomUUID(), sessionId = randomUUID(), clearedId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Goal migration', 'GMG')`;
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Goal agent', 'engineer', 'paperclip_runner')`;
const goal = { objective: "Keep the preview objective", status: "paused" };
await sql`INSERT INTO agent_task_sessions (id, company_id, agent_id, adapter_type, task_key, goal_json, goal_status, goal_revision)
VALUES (${sessionId}, ${companyId}, ${agentId}, 'paperclip_runner', 'active', ${sql.json(goal)}, 'paused', 7),
(${clearedId}, ${companyId}, ${agentId}, 'paperclip_runner', 'cleared', NULL, NULL, 9)`;
await sql`INSERT INTO agent_session_goal_actions (company_id, session_id, request_id, action, payload_json)
VALUES (${companyId}, ${sessionId}, 'resume-once', 'resume', '{}')`;
for (let pass = 0; pass < 2; pass++) {
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await sql.unsafe(statement);
}
}
const sessions = await sql`SELECT task_key, goal_json, goal_status, goal_revision FROM agent_task_sessions
WHERE company_id = ${companyId} ORDER BY task_key`;
expect([...sessions]).toEqual([
{ task_key: "active", goal_json: goal, goal_status: "paused", goal_revision: 7 },
{ task_key: "cleared", goal_json: null, goal_status: null, goal_revision: 9 },
]);
const actions = await sql`SELECT request_id, action, status FROM agent_session_goal_actions WHERE company_id = ${companyId}`;
expect([...actions]).toEqual([{ request_id: "resume-once", action: "resume", status: "pending" }]);
await expect(sql`INSERT INTO agent_session_goal_actions (company_id, session_id, request_id, action, payload_json)
VALUES (${companyId}, ${sessionId}, 'resume-once', 'resume', '{}')`).rejects.toMatchObject({ code: "23505" });
await sql`DELETE FROM agent_task_sessions WHERE company_id = ${companyId}`;
expect(await sql`SELECT id FROM agent_session_goal_actions WHERE company_id = ${companyId}`).toHaveLength(0);
} finally {
await sql.end();
await database.cleanup();
}
}, 30_000);
});

View File

@ -1,8 +1,8 @@
# PRP v1 Contract
# PRP v1/v2 Contract
The JSON Schema files in `schemas/` are the language-neutral source of truth for
Paperclip Runner Protocol version 1. The fixtures in `fixtures/` define accepted
and rejected compatibility cases.
Paperclip Runner Protocol versions 1 and 2. The fixtures in `fixtures/` define
accepted and rejected compatibility cases.
## Compatibility
@ -15,10 +15,38 @@ and rejected compatibility cases.
it meaning.
- A required field, enum value, or typed structured-input field is not optional.
- Question and answer identifiers are stable across the provider boundary.
- Peers negotiate the highest mutually supported protocol version. Existing v1
runners remain compatible but cannot receive v2-only session-goal commands.
The `unknown-optional-fields.json` fixture must be accepted. The
`unsupported-required-version.json` fixture must be rejected.
## Session goals (v2)
PRP v2 adds a provider-neutral durable session-goal lifecycle. Every v2
capability snapshot includes `sessionGoals`, even when its availability is
`unsupported` or `policy_disabled`. Paperclip sends controls only when the
negotiated capability advertises the corresponding action.
Commands:
- `session.goal.get`
- `session.goal.set` for objective, status, and optional token budget changes
- `session.goal.clear`
Events:
- `session.capabilities.updated`
- `session.goal.snapshot`
- `session.goal.updated`
- `session.goal.cleared`
Goal state is separate from Paperclip's company/business goal hierarchy. The
snapshot distinguishes the durable status from `workingNow`, because an active
goal can be idle between autonomous turns. A runner emits the full capability
and authoritative snapshot after every session open or resume. Missing v1
capability is unsupported; clients do not infer support from an adapter name.
## Scope
The first provider descriptor and adapter fixture cover Codex only. The schemas

View File

@ -1,6 +1,6 @@
{
"schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 2,
"name": "Unsupported required version", "description": "A fixture that requires PRP v2 must fail closed in a PRP v1 consumer.",
"schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 3,
"name": "Unsupported required version", "description": "A fixture that requires PRP v3 must fail closed in a PRP v2 consumer.",
"identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_v2", "runId": "run_replay_v2", "environmentLeaseId": "lease_replay_v2", "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_v2", "driverSessionId": "driver_replay_v2" },
"capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "future", "version": "2.0.0" }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true },
"commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_v2_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T13:00:00.000Z", "payload": {} }],

View File

@ -1,17 +1,27 @@
{
"schema": "paperclip.prp.contract_manifest.v1",
"protocolVersion": 1,
"protocolVersion": 2,
"fixtureVersion": 1,
"generatedFrom": [
"protocol/schemas",
"protocol/fixtures"
],
"schemas": [
{
"path": "schemas/capabilities-v2.schema.json",
"id": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json",
"sha256": "9c4771e778f20a0deab6702d0c0f2f5fbab29a60c56a5a78d9ce5290df1728cd"
},
{
"path": "schemas/capabilities.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json",
"sha256": "e62a80328e5edde136b35463033280922ac2220575a83046b07fe034d233db88"
},
{
"path": "schemas/command-v2.schema.json",
"id": "https://paperclip.dev/schemas/prp/v2/command.schema.json",
"sha256": "06bb3b3b0b420119007494c64e64a736fd5d58cb68d04ced843ada00c54668be"
},
{
"path": "schemas/command.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/command.schema.json",
@ -27,6 +37,11 @@
"id": "https://paperclip.dev/schemas/prp/v1/conformance-output.schema.json",
"sha256": "96f78846dffc4a8300184751b9943214506f32ee1f393c6c0c453b666cf12d59"
},
{
"path": "schemas/event-v2.schema.json",
"id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
"sha256": "d7528ee2a46ad20de831fd4ee4a0a51e8325a9855fed5a9b8c428dcebc39cc11"
},
{
"path": "schemas/event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
@ -35,7 +50,7 @@
{
"path": "schemas/fixture.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json",
"sha256": "8ac87c2d8c639981277b5f72b9757a95badf85ff482d11596e4c63e01c616cba"
"sha256": "186caa4a8fa00964f9a76e3714563bcb2c606eee434cef97a5077dd06f3c6aa5"
},
{
"path": "schemas/identity.schema.json",
@ -87,6 +102,11 @@
"id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json",
"sha256": "72dba6b17be1358160633e2ac430002b8be1ab311a236289a942cb05b21385da"
},
{
"path": "schemas/session-goal.schema.json",
"id": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json",
"sha256": "26b36add3657b42e6c3d40895a75a7b5093c70261072fa761f53f6784175320c"
},
{
"path": "schemas/stop-reason.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json",
@ -434,7 +454,7 @@
},
{
"path": "fixtures/replay/unsupported-required-version.json",
"sha256": "60ac964d2cf49dc80ada0cb9c946b25952f3e2be10719801d3f2f8bd98d27699",
"sha256": "c7fc47b1e537c8e051353235aa0415521c627c3e90ac681a053b85c9aa5bf8e8",
"expectation": "reject",
"compatibilityCase": "unknown-required-version"
}

View File

@ -21,6 +21,9 @@
"tool.resolve",
"session.read",
"session.snapshot",
"session.goal.get",
"session.goal.set",
"session.goal.clear",
"session.suspend",
"session.close"
]
@ -33,7 +36,8 @@
"runtime.tool_called",
"runtime.turn_terminal",
"runtime.process",
"runtime.diagnostic"
"runtime.diagnostic",
"runtime.goal"
]
},
"request": {

View File

@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json",
"title": "PRP v2 negotiated capabilities",
"type": "object",
"required": [
"schema",
"sessionReusePolicy",
"driver",
"steer",
"interrupt",
"resume",
"runtimeRequests",
"structuredResult",
"typedEvents",
"sessionGoals"
],
"properties": {
"schema": { "const": "paperclip.prp.capabilities.v2" },
"sessionReusePolicy": { "enum": ["new_per_run", "reuse_per_issue", "reuse_per_workspace"] },
"driver": {
"type": "object",
"required": ["kind", "version"],
"properties": {
"kind": { "type": "string", "minLength": 1, "maxLength": 80 },
"version": { "type": "string", "minLength": 1, "maxLength": 80 }
},
"additionalProperties": true
},
"steer": { "type": "boolean" },
"interrupt": { "type": "boolean" },
"resume": { "type": "boolean" },
"runtimeRequests": { "type": "boolean" },
"structuredResult": { "type": "boolean" },
"typedEvents": { "type": "boolean" },
"sessionGoals": { "$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability" },
"unsupported": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": true
}

View File

@ -0,0 +1,53 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/command.schema.json",
"title": "PRP v2 runner command",
"type": "object",
"required": ["schema", "commandId", "controllerSeq", "type", "issuedAt", "payload"],
"properties": {
"schema": { "const": "paperclip.prp.command.v2" },
"commandId": { "type": "string", "minLength": 1, "maxLength": 160 },
"controllerSeq": { "type": "integer", "minimum": 1 },
"type": {
"enum": [
"run.prepare", "run.attach", "session.open", "turn.start", "turn.steer", "turn.interrupt", "turn.stop",
"request.resolve", "interaction.receipt", "semantic_tool.result", "session.snapshot", "session.close",
"session.budget.increase", "session.destroy", "run.cancel", "runner.drain", "runner.suspend", "runner.shutdown",
"session.goal.get", "session.goal.set", "session.goal.clear"
]
},
"issuedAt": { "type": "string", "format": "date-time" },
"deadlineAt": { "type": "string", "format": "date-time" },
"precondition": {
"type": "object",
"properties": {
"runnerState": { "type": "array", "items": { "type": "string" } },
"runState": { "type": "array", "items": { "type": "string" } },
"sessionState": { "type": "array", "items": { "type": "string" } },
"activeTurnId": { "type": ["string", "null"] }
},
"additionalProperties": true
},
"payload": { "type": "object", "additionalProperties": true }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "session.goal.set" } } },
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"requestId": { "type": "string", "minLength": 1, "maxLength": 160 },
"objective": { "type": "string", "minLength": 1, "maxLength": 4000 },
"status": { "enum": ["active", "paused"] },
"tokenBudget": { "type": ["integer", "null"], "minimum": 1 }
},
"additionalProperties": true
}
}
}
}
],
"additionalProperties": true
}

View File

@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
"title": "PRP v2 native event",
"type": "object",
"required": [
"schema", "sourceEventId", "sourceSeq", "sourceInstanceId", "sourceKind", "runId",
"eventType", "schemaVersion", "priority", "emittedAt", "payload"
],
"properties": {
"schema": { "const": "paperclip.prp.event.v2" },
"sourceEventId": { "type": "string", "minLength": 1, "maxLength": 160 },
"sourceSeq": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
"sourceInstanceId": { "type": "string", "minLength": 1, "maxLength": 160 },
"sourceKind": { "enum": ["runner", "control_plane"] },
"runId": { "type": "string", "minLength": 1, "maxLength": 160 },
"normalizedSessionId": { "type": "string", "minLength": 1, "maxLength": 160 },
"turnId": { "type": "string", "minLength": 1, "maxLength": 160 },
"itemId": { "type": "string", "minLength": 1, "maxLength": 160 },
"eventType": {
"enum": [
"runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic",
"runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced",
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic", "plan.updated",
"tool.execution.started", "tool.execution.progressed", "tool.execution.completed", "research.started", "research.progressed", "research.completed",
"delegation.started", "delegation.updated", "delegation.completed", "model.route.changed", "model.verification.updated", "context.compacted",
"artifact.viewed", "artifact.generated", "review.mode.changed", "hook.started", "hook.completed", "memory.citation.referenced",
"safety.review.started", "safety.review.completed", "terminal.input.sent", "wait.started", "wait.completed", "provider.notice.recorded",
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.updated", "session.closed", "session.failed",
"session.capabilities.updated", "session.goal.snapshot", "session.goal.updated", "session.goal.cleared",
"turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled",
"item.started", "item.delta", "item.completed", "item.failed", "usage.reported", "semantic_tool.input", "semantic_tool.result",
"mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown",
"runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled",
"interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered",
"run.attached", "run.detached", "run.result.proposed", "run.result.accepted", "run.result.rejected",
"attention.request.proposed", "attention.request.routed", "attention.request.resolved", "attention.request.expired", "attention.request.superseded",
"work.assessment.recorded", "issue.status.decision.recorded", "issue.status.decision.applied", "issue.status.decision.rejected", "issue.status.decision.superseded", "run.terminal"
]
},
"schemaVersion": { "const": 2 },
"priority": { "enum": [0, 1, 2] },
"emittedAt": { "type": "string", "format": "date-time" },
"observedAt": { "type": "string", "format": "date-time" },
"payload": { "type": "object", "additionalProperties": true },
"debug": { "type": "object", "additionalProperties": true }
},
"allOf": [
{
"if": { "properties": { "eventType": { "enum": ["session.goal.snapshot", "session.goal.updated"] } } },
"then": {
"properties": {
"payload": {
"type": "object",
"required": ["goal"],
"properties": {
"goal": {
"oneOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot" },
{ "type": "null" }
]
}
},
"additionalProperties": true
}
}
}
},
{
"if": { "properties": { "eventType": { "const": "session.capabilities.updated" } } },
"then": {
"properties": {
"payload": {
"type": "object",
"required": ["sessionGoals"],
"properties": {
"sessionGoals": { "$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability" }
},
"additionalProperties": true
}
}
}
}
],
"additionalProperties": true
}

View File

@ -18,19 +18,34 @@
"properties": {
"schema": { "const": "paperclip.prp.fixture.v1" },
"fixtureVersion": { "const": 1 },
"protocolVersion": { "const": 1 },
"protocolVersion": { "enum": [1, 2] },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "minLength": 1, "maxLength": 1000 },
"identity": { "$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json" },
"capabilities": { "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json" },
"capabilities": {
"oneOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json" },
{ "$ref": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json" }
]
},
"commands": {
"type": "array",
"items": { "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json" }
"items": {
"oneOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json" },
{ "$ref": "https://paperclip.dev/schemas/prp/v2/command.schema.json" }
]
}
},
"events": {
"type": "array",
"minItems": 1,
"items": { "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" }
"items": {
"oneOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" },
{ "$ref": "https://paperclip.dev/schemas/prp/v2/event.schema.json" }
]
}
},
"requests": {
"type": "array",

View File

@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json",
"title": "PRP v2 session goal capability and snapshot",
"$defs": {
"capability": {
"type": "object",
"required": [
"availability",
"actions",
"autonomousUpdates",
"persistentAcrossResume",
"maxObjectiveChars",
"tokenBudgetControl",
"usageReporting"
],
"properties": {
"availability": { "enum": ["available", "unsupported", "policy_disabled"] },
"actions": {
"type": "array",
"items": { "enum": ["set", "pause", "resume", "clear"] },
"uniqueItems": true
},
"autonomousUpdates": { "type": "boolean" },
"persistentAcrossResume": { "type": "boolean" },
"maxObjectiveChars": { "type": "integer", "minimum": 1, "maximum": 4000 },
"tokenBudgetControl": { "type": "boolean" },
"usageReporting": { "type": "boolean" },
"reasonCode": { "type": "string", "minLength": 1, "maxLength": 160 },
"reason": { "type": "string", "minLength": 1, "maxLength": 1000 }
},
"additionalProperties": true
},
"snapshot": {
"type": "object",
"required": [
"objective",
"status",
"tokenBudget",
"tokensUsed",
"elapsedSeconds",
"iterations",
"lastReason",
"createdAt",
"updatedAt",
"completedAt",
"workingNow"
],
"properties": {
"objective": { "type": "string", "minLength": 1, "maxLength": 4000 },
"status": { "enum": ["active", "paused", "blocked", "limited", "usage_limited", "budget_limited", "complete"] },
"tokenBudget": { "type": ["integer", "null"], "minimum": 1 },
"tokensUsed": { "type": ["integer", "null"], "minimum": 0 },
"elapsedSeconds": { "type": ["number", "null"], "minimum": 0 },
"iterations": { "type": ["integer", "null"], "minimum": 0 },
"lastReason": { "type": ["string", "null"], "maxLength": 4000 },
"createdAt": { "type": ["string", "null"], "format": "date-time" },
"updatedAt": { "type": ["string", "null"], "format": "date-time" },
"completedAt": { "type": ["string", "null"], "format": "date-time" },
"workingNow": { "type": "boolean" }
},
"additionalProperties": true
}
}
}

View File

@ -70,6 +70,9 @@ pub enum AcpxEventPayload {
Process {
details: Value,
},
Goal {
details: Value,
},
Diagnostic {
code: String,
message: String,
@ -158,6 +161,9 @@ pub fn decode_acpx_event(
GeneratedAcpxSidecarEventType::RuntimeProcess => Ok(AcpxEventPayload::Process {
details: sanitize_value(&event.payload),
}),
GeneratedAcpxSidecarEventType::RuntimeGoal => Ok(AcpxEventPayload::Goal {
details: sanitize_value(&event.payload),
}),
GeneratedAcpxSidecarEventType::RuntimeDiagnostic => {
let code = required_id(&event.payload, "code", "diagnostic code")?;
let message = event

View File

@ -134,6 +134,7 @@ impl AcpxEventScope {
event.event_type,
GeneratedAcpxSidecarEventType::RuntimeProcess
| GeneratedAcpxSidecarEventType::RuntimeDiagnostic
| GeneratedAcpxSidecarEventType::RuntimeGoal
);
match event.run_id.as_deref() {

View File

@ -24,6 +24,7 @@ use crate::durable::{
AcpxLaunchProfile, Command, CommandExecution, CommandExecutor, DurableRunnerConfig,
DurableRunnerError, EventPriority, PolledEvent,
};
use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarCommand;
use crate::process_supervisor::{VerifiedProcessArgument, VerifiedProcessLaunch};
use crate::provider_bridge::{
authorized_tool_catalog_digest, AuthorizedToolSet, ToolResult, TOOL_SET_SCHEMA,
@ -155,7 +156,7 @@ impl AcpxProviderDescriptor {
"1.6.2",
Some("@openai/codex"),
Some("0.153.4"),
"sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400",
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
),
"pi" => return Err(DurableRunnerError::invalid(
"ACPX agent pi is not executable through the verified runnerd provider boundary",
@ -361,6 +362,12 @@ struct AcpxDurableState {
#[serde(default)]
semantic_result: Option<Value>,
#[serde(default)]
goal_projection: Value,
#[serde(default)]
goal_revision: u64,
#[serde(default)]
goal_source_revision: Option<u64>,
#[serde(default)]
pending_events: VecDeque<PolledEvent>,
#[serde(default = "initial_event_sequence")]
next_event_sequence: u64,
@ -382,6 +389,9 @@ impl AcpxDurableState {
active_turn_id: None,
provider_exit_unconfirmed: false,
semantic_result: None,
goal_projection: Value::Null,
goal_revision: 0,
goal_source_revision: None,
pending_events: VecDeque::new(),
next_event_sequence: initial_event_sequence(),
}
@ -841,6 +851,7 @@ impl AcpxCommandExecutor {
state.active_turn_id = None;
state.lifecycle = "session_open".to_owned();
let payload = session_event_payload(&state.descriptor, &identity, process_id);
let goal = self.goal_control("session.goal.get", &json!({}))?;
self.save_state()?;
Ok(CommandExecution {
result: json!({
@ -852,7 +863,7 @@ impl AcpxCommandExecutor {
"sessionId": identity.agent_session_id,
"processId": process_id,
}),
events: vec![(
events: [(
if resumed {
"session.resumed"
} else {
@ -861,7 +872,98 @@ impl AcpxCommandExecutor {
.to_owned(),
EventPriority::P0,
payload,
)],
)]
.into_iter()
.chain(goal.events)
.collect(),
})
}
fn goal_control(
&mut self,
command: &str,
payload: &Value,
) -> Result<CommandExecution, DurableRunnerError> {
let sidecar_command = match command {
"session.goal.get" => GeneratedAcpxSidecarCommand::SessionGoalGet,
"session.goal.set" => GeneratedAcpxSidecarCommand::SessionGoalSet,
"session.goal.clear" => GeneratedAcpxSidecarCommand::SessionGoalClear,
_ => return Err(DurableRunnerError::invalid("unknown ACPX goal control")),
};
if command != "session.goal.get" {
let action = if command == "session.goal.clear" {
"clear"
} else if payload.get("objective").is_some() {
"set"
} else if payload.get("status").and_then(Value::as_str) == Some("paused") {
"pause"
} else {
"resume"
};
let available = self.state.as_ref().is_some_and(|state| {
state
.goal_projection
.pointer("/sessionGoals/availability")
.and_then(Value::as_str)
== Some("available")
&& state
.goal_projection
.pointer("/sessionGoals/actions")
.and_then(Value::as_array)
.is_some_and(|actions| {
actions.iter().any(|value| value.as_str() == Some(action))
})
});
if !available {
return Ok(CommandExecution::result(
json!({"status":"rejected", "code":"session_goal_action_unavailable", "message":"The negotiated ACP extension does not support this goal action"}),
));
}
}
let mut projection = self
.session
.as_mut()
.ok_or_else(|| {
DurableRunnerError::invalid("ACPX goal control requires an open session")
})?
.goal_control(sidecar_command, payload.clone())
.map_err(|error| {
DurableRunnerError::invalid(format!("ACPX goal control failed: {error}"))
})?;
if projection.get("schema").and_then(Value::as_str)
!= Some("paperclip.session_goal.snapshot.v1")
{
return Err(DurableRunnerError::invalid(
"ACPX returned an invalid goal snapshot",
));
}
let state = self
.state
.as_mut()
.expect("open ACPX session has durable state");
state.goal_revision += 1;
state.goal_source_revision = projection.get("providerRevision").and_then(Value::as_u64);
projection["revision"] = json!(state.goal_revision);
if let Some(request_id) = payload.get("requestId") {
projection["requestId"] = request_id.clone();
}
state.goal_projection = projection.clone();
self.save_state()?;
let event_type = match command {
"session.goal.clear" => "session.goal.cleared",
"session.goal.set" => "session.goal.updated",
_ => "session.goal.snapshot",
};
Ok(CommandExecution {
result: projection.clone(),
events: vec![
(
"session.capabilities.updated".to_owned(),
EventPriority::P0,
json!({"sessionGoals":projection["sessionGoals"]}),
),
(event_type.to_owned(), EventPriority::P0, projection),
],
})
}
@ -1236,11 +1338,40 @@ impl AcpxCommandExecutor {
)
.then(|| event.event_type.clone())
});
if terminal.is_some() {
let snapshot = self.goal_control("session.goal.get", &json!({}))?;
let state = self.state.as_mut().expect("ACPX session has durable state");
for (event_type, priority, payload) in snapshot.events {
state.push(NormalizedProviderEvent {
event_type,
priority,
payload,
})?;
}
}
let state = self
.state
.as_mut()
.expect("ACPX state remains available while polling");
for event in normalized {
for mut event in normalized {
if matches!(
event.event_type.as_str(),
"session.goal.updated" | "session.goal.cleared"
) {
let revision = event
.payload
.get("providerRevision")
.and_then(Value::as_u64);
// Requests consume a newer authoritative snapshot while
// older notifications may still be queued in the transport.
if !goal_notification_is_newer(revision, state.goal_source_revision) {
continue;
}
state.goal_source_revision = revision;
state.goal_revision += 1;
event.payload["revision"] = json!(state.goal_revision);
state.goal_projection = event.payload.clone();
}
if event.event_type == "run.result.proposed" {
state.semantic_result = Some(event.payload.clone());
}
@ -1250,22 +1381,34 @@ impl AcpxCommandExecutor {
state.active_turn_id = None;
state.lifecycle = "session_open".to_owned();
provider_turn_settled = true;
// An ACP goal has session lifetime, not prompt lifetime.
// Out-of-prompt goal updates remain observable after quiescence.
if state
.goal_projection
.pointer("/goal/status")
.and_then(Value::as_str)
== Some("active")
{
continue;
}
let status = match event_type.as_str() {
"turn.completed" => "succeeded",
"turn.cancelled" => "cancelled",
"turn.interrupted" => "interrupted",
_ => "failed",
};
let disposition = state
.semantic_result
.as_ref()
.and_then(|result| result.get("reportedWorkDisposition"))
.and_then(Value::as_str)
.unwrap_or(if status == "succeeded" {
"done"
} else {
"needs_review"
});
let disposition = goal_terminal_disposition(
state
.goal_projection
.pointer("/goal/status")
.and_then(Value::as_str),
state
.semantic_result
.as_ref()
.and_then(|result| result.get("reportedWorkDisposition"))
.and_then(Value::as_str),
status == "succeeded",
);
state.push(NormalizedProviderEvent {
event_type: "run.terminal".to_owned(),
priority: EventPriority::P0,
@ -1287,6 +1430,23 @@ impl AcpxCommandExecutor {
}
}
fn goal_notification_is_newer(next: Option<u64>, last: Option<u64>) -> bool {
next.is_some_and(|next| last.is_none_or(|last| next > last))
}
fn goal_terminal_disposition<'a>(
goal_status: Option<&str>,
semantic_disposition: Option<&'a str>,
succeeded: bool,
) -> &'a str {
match goal_status {
Some("blocked") => "blocked",
Some("paused" | "limited" | "usage_limited" | "budget_limited") => "yielded",
Some("complete") => "done",
_ => semantic_disposition.unwrap_or(if succeeded { "done" } else { "needs_review" }),
}
}
impl CommandExecutor for AcpxCommandExecutor {
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
self.restore()?;
@ -1317,6 +1477,9 @@ impl CommandExecutor for AcpxCommandExecutor {
Ok(execution)
}
"session.open" => self.open_session(),
"session.goal.get" | "session.goal.set" | "session.goal.clear" => {
self.goal_control(&command.command_type, &command.payload)
}
"turn.start" => self.start_turn(&command.payload),
"turn.steer" => Ok(CommandExecution::result(json!({
"status": "rejected",
@ -1611,7 +1774,7 @@ mod tests {
"1.6.2",
json!("@openai/codex"),
json!("0.153.4"),
"sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400",
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
)
};
json!({
@ -1653,6 +1816,38 @@ mod tests {
assert!(drifted.validate(&context()).is_err());
}
#[test]
fn authoritative_clear_fences_queued_and_unsequenced_goal_notifications() {
assert!(!goal_notification_is_newer(Some(3), Some(4)));
assert!(!goal_notification_is_newer(Some(4), Some(4)));
assert!(!goal_notification_is_newer(None, Some(4)));
assert!(goal_notification_is_newer(Some(5), Some(4)));
assert!(goal_notification_is_newer(Some(1), None));
}
#[test]
fn goal_state_overrides_optimistic_prompt_disposition() {
assert_eq!(
goal_terminal_disposition(Some("blocked"), Some("done"), true),
"blocked"
);
for status in ["paused", "limited", "usage_limited", "budget_limited"] {
assert_eq!(
goal_terminal_disposition(Some(status), Some("done"), true),
"yielded"
);
}
assert_eq!(
goal_terminal_disposition(Some("complete"), Some("yielded"), true),
"done"
);
assert_eq!(
goal_terminal_disposition(None, Some("yielded"), true),
"yielded"
);
assert_eq!(goal_terminal_disposition(None, None, false), "needs_review");
}
#[test]
fn describes_process_replacement_as_same_session_continuity() {
let identity = AcpxProviderSessionIdentity {

View File

@ -269,6 +269,24 @@ impl AcpxProviderSession {
self.catalog_revision
}
/// Session controls are independent of a prompt's receipt epoch.
pub fn goal_control(
&mut self,
command: GeneratedAcpxSidecarCommand,
payload: Value,
) -> Result<Value, LocalRunnerError> {
self.ensure_open()?;
if !matches!(
command,
GeneratedAcpxSidecarCommand::SessionGoalGet
| GeneratedAcpxSidecarCommand::SessionGoalSet
| GeneratedAcpxSidecarCommand::SessionGoalClear
) {
return Err(LocalRunnerError::invalid("not an ACPX goal control"));
}
self.transport.request(command, payload)
}
pub fn start_turn(
&mut self,
turn_id: &str,

View File

@ -72,6 +72,7 @@ pub enum AcpxProviderStateEvent {
error: Option<Value>,
},
Process(Value),
Goal(Value),
Diagnostic {
code: String,
message: String,
@ -340,6 +341,7 @@ impl AcpxProviderState {
AcpxEventPayload::Process { details } => {
Ok(vec![AcpxProviderStateEvent::Process(details)])
}
AcpxEventPayload::Goal { details } => Ok(vec![AcpxProviderStateEvent::Goal(details)]),
AcpxEventPayload::Diagnostic { code, message } => {
Ok(vec![AcpxProviderStateEvent::Diagnostic { code, message }])
}

View File

@ -28,6 +28,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
let mut next_sequence = 1_u64;
let mut goal = Value::Null;
for line in stdin.lock().lines() {
let request: Value = serde_json::from_str(&line?)?;
let id = request
@ -38,6 +39,43 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.get("command")
.and_then(Value::as_str)
.ok_or("request command is missing")?;
if mode == "goals" && command.starts_with("session.goal.") {
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
match command {
"session.goal.set" => {
goal = json!({
"objective":params.get("objective").cloned().unwrap_or_else(|| goal["objective"].clone()),
"status":params.get("status").cloned().unwrap_or_else(|| json!("active")),
"tokenBudget":null,"tokensUsed":null,"elapsedSeconds":null,"iterations":null,
"lastReason":null,"createdAt":null,"updatedAt":null,"completedAt":null,"workingNow":false,
});
}
"session.goal.clear" => goal = Value::Null,
_ => {}
}
let projection = json!({
"schema":"paperclip.session_goal.snapshot.v1", "goal":goal,"workingNow":false,
"sessionGoals":{"availability":"available","actions":["set","pause","resume","clear"],
"autonomousUpdates":true,"persistentAcrossResume":true,"maxObjectiveChars":4000,
"tokenBudgetControl":false,"usageReporting":false},
});
if command != "session.goal.get" {
write_json(
&mut stdout,
&json!({
"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,"sequence":next_sequence,
"eventType":"runtime.goal","runId":"run-1","turnId":null,"payload":projection,
}),
)?;
next_sequence += 1;
}
write_json(
&mut stdout,
&json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"id":id,"ok":true,"result":projection}),
)?;
continue;
}
if command == "permission.resolve" {
write_json(
&mut stdout,
@ -95,6 +133,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(9);
}
"bootstrap"
| "goals"
| "bootstrap-wrong-model"
| "bootstrap-wrong-run"
| "turns"
@ -636,6 +675,11 @@ fn bootstrap_success(
== Some(PROJECTED_INPUT_PROVIDER_ID),
}),
"session.close" => json!({"closed":true}),
"session.goal.get" => json!({
"schema":"paperclip.session_goal.snapshot.v1", "goal":null, "workingNow":false,
"sessionGoals": {"availability":"unsupported", "actions":[], "autonomousUpdates":false,
"persistentAcrossResume":false, "maxObjectiveChars":4000, "tokenBudgetControl":false, "usageReporting":false}
}),
_ => json!({"command":command,"params":params}),
};
json!({

View File

@ -15,6 +15,8 @@ struct FakeState {
active_turn_id: Option<String>,
#[serde(default)]
next_turn: u64,
#[serde(default)]
goal: Option<Value>,
}
fn argument(args: &[String], name: &str) -> Option<String> {
@ -81,6 +83,7 @@ fn load_state(path: &Path) -> FakeState {
thread_id: "codex-thread-1".to_owned(),
active_turn_id: None,
next_turn: 0,
goal: None,
})
}
@ -136,7 +139,8 @@ fn matches_task_context_result(result: &Value, expected_canonical: Option<&Value
.all(|(actual_pointer, expected_pointer)| {
let actual = result.pointer(actual_pointer).and_then(Value::as_str);
let expected = expected.pointer(expected_pointer).and_then(Value::as_str);
actual.is_some_and(|value| !value.is_empty()) && actual == expected
actual.is_some_and(|value| !value.is_empty())
&& (actual == expected || (expected_pointer == "/runId" && expected.is_none()))
})
}
@ -690,6 +694,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let pre_response_notification = args
.iter()
.any(|value| value == "--notification-before-response");
let goal_policy_disabled = args.iter().any(|value| value == "--goal-policy-disabled");
let goal_autostart = args.iter().any(|value| value == "--goal-autostart");
let goal_autocontinue = args.iter().any(|value| value == "--goal-autocontinue");
let goal_item_trigger = argument(&args, "--goal-item-trigger");
let reject_goal_set = args.iter().any(|value| value == "--reject-goal-set");
let agent_created_goal = args
.iter()
.any(|value| value == "--agent-created-goal-on-open");
if require_skill_instructions {
let skill_path = std::env::var_os("HOME")
.map(PathBuf::from)
@ -870,6 +882,19 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
state.thread_id = "codex-thread-1".to_owned();
state.active_turn_id = None;
if agent_created_goal {
state.goal = Some(json!({
"objective": "Goal created by the Codex agent",
"status": "active",
"tokenBudget": null,
"tokensUsed": 0,
"timeUsedSeconds": 0,
"iterations": 0,
"createdAt": "2026-08-28T00:00:00.000Z",
"updatedAt": "2026-08-28T00:00:00.000Z",
"completedAt": null
}));
}
save_state(&state_path, &state)?;
if pre_response_notification {
send(json!({
@ -881,6 +906,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"id": id,
"result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}}
}))?;
if agent_created_goal {
send(json!({
"method": "thread/goal/updated",
"params": {"threadId": state.thread_id, "goal": state.goal}
}))?;
}
}
"thread/resume" => {
if require_external_sandbox
@ -943,6 +974,110 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
}
"thread/goal/get" if goal_policy_disabled => send(json!({
"id": id,
"error": {"code": -32004, "message": "goal feature disabled by provider policy"}
}))?,
"thread/goal/get" => send(json!({
"id": id,
"result": {"goal": state.goal}
}))?,
"thread/goal/set" => {
if reject_goal_set {
send(json!({
"id": id,
"error": {"code": -32000, "message": "goal set rejected"}
}))?;
continue;
}
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
let previous = state.goal.clone().unwrap_or_else(|| json!({}));
let objective = params
.get("objective")
.cloned()
.or_else(|| previous.get("objective").cloned())
.unwrap_or_else(|| json!("Fake Codex goal"));
let status = params
.get("status")
.cloned()
.or_else(|| previous.get("status").cloned())
.unwrap_or_else(|| json!("active"));
let token_budget = params
.get("tokenBudget")
.cloned()
.or_else(|| previous.get("tokenBudget").cloned())
.unwrap_or(Value::Null);
state.goal = Some(json!({
"objective": objective,
"status": status,
"tokenBudget": token_budget,
"tokensUsed": previous.get("tokensUsed").cloned().unwrap_or_else(|| json!(0)),
"timeUsedSeconds": previous.get("timeUsedSeconds").cloned().unwrap_or_else(|| json!(0)),
"iterations": previous.get("iterations").cloned().unwrap_or_else(|| json!(0)),
"createdAt": previous.get("createdAt").cloned().unwrap_or_else(|| json!("2026-08-28T00:00:00.000Z")),
"updatedAt": "2026-08-28T00:00:01.000Z",
"completedAt": null
}));
if goal_autostart
&& state
.goal
.as_ref()
.and_then(|goal| goal.get("status"))
.and_then(Value::as_str)
== Some("active")
{
state.active_turn_id = Some("provider-goal-turn-1".to_owned());
}
save_state(&state_path, &state)?;
send(json!({"id": id, "result": {"goal": state.goal}}))?;
send(json!({
"method": "thread/goal/updated",
"params": {"threadId": state.thread_id, "goal": state.goal}
}))?;
if state.active_turn_id.is_some() {
send(json!({
"method": "turn/started",
"params": {"turn": {"id": "provider-goal-turn-1"}}
}))?;
if let Some(trigger) = goal_item_trigger.clone() {
let thread_id = state.thread_id.clone();
thread::spawn(move || {
for _ in 0..3_000 {
if PathBuf::from(&trigger).is_file() {
if send(json!({"method":"item/started", "params":{
"threadId":thread_id, "turnId":"provider-goal-turn-1",
"item":{"id":"mid-recovery-item", "type":"agentMessage", "text":"Continuing after disconnect"}
}})).is_ok() {
let _ = fs::write(format!("{trigger}.sent"), "sent");
}
break;
}
thread::sleep(Duration::from_millis(10));
}
});
}
if goal_autocontinue {
send(json!({"method":"turn/completed", "params":{
"threadId":state.thread_id,
"turn":{"id":"provider-goal-turn-1", "status":"completed", "items":[]}
}}))?;
state.active_turn_id = Some("provider-goal-turn-2".to_owned());
save_state(&state_path, &state)?;
send(json!({"method":"turn/started", "params":{
"threadId":state.thread_id, "turn":{"id":"provider-goal-turn-2"}
}}))?;
}
}
}
"thread/goal/clear" => {
state.goal = None;
save_state(&state_path, &state)?;
send(json!({"id": id, "result": {"cleared": true}}))?;
send(json!({
"method": "thread/goal/cleared",
"params": {"threadId": state.thread_id}
}))?;
}
"turn/start" => {
if require_external_sandbox
&& (message.pointer("/params/sandboxPolicy")
@ -1276,24 +1411,38 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
if emit_post_completion_foreign_turn {
if let Some(gate) = post_completion_notification_gate.as_ref() {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !gate.is_file() {
if std::time::Instant::now() >= deadline {
return Err(
"post-completion notification gate timed out".into()
);
let gate = post_completion_notification_gate.clone();
let thread_id = state.thread_id.clone();
// Keep serving authoritative goal reads while the test waits
// for the completed turn before releasing the tail frame.
thread::spawn(move || {
let result = (|| -> io::Result<()> {
if let Some(gate) = gate.as_ref() {
let deadline =
std::time::Instant::now() + Duration::from_secs(5);
while !gate.is_file() {
if std::time::Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"post-completion notification gate timed out",
));
}
thread::sleep(Duration::from_millis(1));
}
}
thread::sleep(Duration::from_millis(1));
send(json!({
"method": "turn/started",
"params": {"threadId": thread_id, "turn": {"id": "unowned-turn"}}
}))?;
if let Some(gate) = gate.as_ref() {
fs::write(gate.with_extension("emitted"), b"emitted")?;
}
Ok(())
})();
if let Err(error) = result {
eprintln!("failed to emit post-completion foreign turn: {error}");
}
}
send(json!({
"method": "turn/started",
"params": {"threadId": state.thread_id, "turn": {"id": "unowned-turn"}}
}))?;
if let Some(gate) = post_completion_notification_gate.as_ref() {
fs::write(gate.with_extension("emitted"), b"emitted")?;
}
});
}
if fail_after_turn_completion {
if let Some(delay_ms) = fail_after_turn_completion_delay_ms {

View File

@ -124,7 +124,7 @@ fn build_metadata() -> serde_json::Value {
"prp": {
"name": "paperclip.runner",
"minimumVersion": 1,
"maximumVersion": 1
"maximumVersion": 2
},
"prpTransportModes": ["dial_ws_loopback", "dial_wss", "listen_ws"]
})
@ -140,6 +140,16 @@ fn value(args: &[String], name: &str) -> Result<String, LocalRunnerError> {
.ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}")))
}
fn optional_value(args: &[String], name: &str) -> Result<Option<String>, LocalRunnerError> {
let Some(index) = args.iter().position(|argument| argument == name) else {
return Ok(None);
};
args.get(index + 1)
.cloned()
.map(Some)
.ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}")))
}
fn optional_u64(args: &[String], name: &str) -> Result<Option<u64>, LocalRunnerError> {
let Some(index) = args.iter().position(|argument| argument == name) else {
return Ok(None);
@ -153,16 +163,6 @@ fn optional_u64(args: &[String], name: &str) -> Result<Option<u64>, LocalRunnerE
.map_err(|error| LocalRunnerError::invalid(format!("invalid {name}: {error}")))
}
fn optional_value(args: &[String], name: &str) -> Result<Option<String>, LocalRunnerError> {
let Some(index) = args.iter().position(|argument| argument == name) else {
return Ok(None);
};
args.get(index + 1)
.cloned()
.map(Some)
.ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}")))
}
fn acpx_launch_profile(args: &[String]) -> Result<Option<AcpxLaunchProfile>, LocalRunnerError> {
let authority_digest = optional_value(args, "--acpx-launch-authority-digest")?;
let command = optional_value(args, "--acpx-sidecar-command")?;

View File

@ -534,6 +534,7 @@ pub struct CodexProvider {
completed_turn_authority: Option<CompletedTurnAuthority>,
active_turn_result_authoritative: bool,
completion_reconciliation_pending: bool,
goal_allows_autonomous_turns: bool,
ambiguous_turn_start_pending: bool,
settled_provider_turn_ids: SettledProviderTurnIds,
rejected_accepted_turn: Option<RejectedAcceptedTurn>,
@ -794,6 +795,7 @@ impl CodexProvider {
completed_turn_authority: None,
active_turn_result_authoritative: false,
completion_reconciliation_pending: false,
goal_allows_autonomous_turns: false,
ambiguous_turn_start_pending: false,
settled_provider_turn_ids: SettledProviderTurnIds::default(),
rejected_accepted_turn: None,
@ -1263,6 +1265,104 @@ impl CodexProvider {
self.restart_idle_identity_epoch()
}
pub fn get_goal(&mut self) -> Result<Value, LocalRunnerError> {
let result = self.request("thread/goal/get", json!({"threadId": self.thread_id}))?;
self.goal_allows_autonomous_turns =
result.pointer("/goal/status").and_then(Value::as_str) == Some("active");
Ok(result)
}
pub fn set_goal(
&mut self,
objective: Option<&str>,
status: Option<&str>,
token_budget: Option<Option<u64>>,
) -> Result<Value, LocalRunnerError> {
let starts_idle_turn = self.active_provider_turn_id.is_none()
&& (status == Some("active") || (status.is_none() && objective.is_some()));
let prior_reconciliation_pending = self.completion_reconciliation_pending;
let prior_buffered_message_count = self.pending_messages.len();
if starts_idle_turn {
if self.quarantined {
return Err(LocalRunnerError::invalid(
"Codex provider is quarantined after unsafe recovered work",
));
}
if self.ambiguous_turn_start_pending {
return Err(LocalRunnerError::invalid(
"Codex has an unresolved ambiguous provider turn start",
));
}
self.rollover_settled_turn_epoch_if_needed()?;
// Activating an idle Codex goal starts a provider turn without a
// turn/start response. Arm the same identity reconciliation used
// by an ambiguous turn/start before sending the request because
// turn/started may be buffered ahead of the goal response.
self.completion_reconciliation_pending = false;
self.ambiguous_turn_start_pending = true;
}
let mut params = json!({"threadId": self.thread_id});
let params = params
.as_object_mut()
.expect("Codex goal parameters are an object");
if let Some(objective) = objective {
params.insert("objective".to_owned(), json!(objective));
}
if let Some(status) = status {
params.insert("status".to_owned(), json!(status));
}
if let Some(token_budget) = token_budget {
params.insert("tokenBudget".to_owned(), json!(token_budget));
}
match self.request_classified("thread/goal/set", Value::Object(params.clone())) {
Ok(result) => {
let effective_status = result
.pointer("/goal/status")
.or_else(|| result.get("status"))
.and_then(Value::as_str);
self.goal_allows_autonomous_turns = effective_status == Some("active");
if starts_idle_turn && effective_status.is_some_and(|value| value != "active") {
// Objective-only updates preserve the provider's current
// goal status. If that status is paused or otherwise
// inactive, no autonomous turn starts. Restore the prior
// reconciliation state unless the response raced with
// contradictory provider-work evidence.
let no_turn_evidence = self
.pending_messages
.iter()
.skip(prior_buffered_message_count)
.all(|buffered| is_non_active_goal_set_diagnostic(&buffered.value));
if no_turn_evidence {
self.ambiguous_turn_start_pending = false;
self.completion_reconciliation_pending = prior_reconciliation_pending;
}
}
Ok(result)
}
Err(ProviderRequestError::Rejected(error)) => {
if starts_idle_turn {
let definite_rejection = self
.pending_messages
.iter()
.skip(prior_buffered_message_count)
.all(|buffered| is_unbound_rejected_turn_diagnostic(&buffered.value));
if definite_rejection {
self.ambiguous_turn_start_pending = false;
self.completion_reconciliation_pending = prior_reconciliation_pending;
}
}
Err(error)
}
Err(ProviderRequestError::Ambiguous(error)) => Err(error),
}
}
pub fn clear_goal(&mut self) -> Result<Value, LocalRunnerError> {
let result = self.request("thread/goal/clear", json!({"threadId": self.thread_id}))?;
self.goal_allows_autonomous_turns = false;
Ok(result)
}
pub fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError> {
if self.quarantined {
return Err(LocalRunnerError::invalid(
@ -1672,6 +1772,23 @@ impl CodexProvider {
self.completion_reconciliation_pending = false;
}
let method = message.get("method").and_then(Value::as_str);
if matches!(method, Some("thread/goal/updated" | "thread/goal/cleared")) {
let params = message.get("params").cloned().unwrap_or(Value::Null);
validate_notification_binding(&self.thread_id, None, &params)?;
self.goal_allows_autonomous_turns = method == Some("thread/goal/updated")
&& params.pointer("/goal/status").and_then(Value::as_str) == Some("active");
}
if method == Some("turn/started")
&& self.active_provider_turn_id.is_none()
&& self.goal_allows_autonomous_turns
&& !self.quarantined
{
// Goal continuation has no turn/start response. Reuse the exact
// ambiguous-start validator, including settled-ID rejection and
// fresh tool/request receipt ownership for the accepted turn.
self.ambiguous_turn_start_pending = true;
}
match self.classify_ambiguous_turn_message(&message)? {
AmbiguousTurnMessage::Ready => {}
AmbiguousTurnMessage::Deferred => {
@ -2536,6 +2653,34 @@ fn is_unbound_rejected_turn_diagnostic(message: &Value) -> bool {
.is_none_or(|params| !contains_provider_work_binding(params))
}
fn is_non_active_goal_set_diagnostic(message: &Value) -> bool {
if message.get("id").is_some() {
return false;
}
match message.get("method").and_then(Value::as_str) {
Some("thread/goal/updated") => message
.get("params")
.is_none_or(|params| !contains_provider_turn_binding(params)),
Some("warning") => message
.get("params")
.is_none_or(|params| !contains_provider_work_binding(params)),
_ => false,
}
}
fn contains_provider_turn_binding(value: &Value) -> bool {
match value {
Value::Array(values) => values.iter().any(contains_provider_turn_binding),
Value::Object(fields) => fields.iter().any(|(key, child)| {
(matches!(key.as_str(), "turnId" | "itemId" | "requestId") && !child.is_null())
|| (matches!(key.as_str(), "turn" | "item" | "request")
&& child.get("id").is_some_and(|id| !id.is_null()))
|| contains_provider_turn_binding(child)
}),
_ => false,
}
}
fn contains_provider_work_binding(value: &Value) -> bool {
match value {
Value::Array(values) => values.iter().any(contains_provider_work_binding),

View File

@ -23,7 +23,8 @@ pub use state::{
pub(crate) use transport::current_unix_ms;
pub const PROTOCOL: &str = "paperclip.runner";
pub const PROTOCOL_VERSION: u64 = 1;
pub const PROTOCOL_MIN_VERSION: u64 = 1;
pub const PROTOCOL_VERSION: u64 = 2;
pub const BOOTSTRAP_TICKET_ENV: &str = "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET";
const MAX_OUTBOX_BYTES: usize = 512 * 1024 * 1024;
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;

View File

@ -6,13 +6,13 @@ use serde_json::{json, Value};
use super::state::{
Command, CommandDisposition, DurableState, DurableStateStore, EventPriority,
PendingTerminalDelivery, StoredCommandResult,
PendingTerminalDelivery, StoredCommandResult, StoredOutboxEvent,
};
use super::transport::{
current_unix_ms, validate_control_identity, AuthenticatedTransport, ConnectionMetadata,
LeaseCredential, RunnerTransportEndpoint,
};
use super::{BootstrapTicket, DurableRunnerConfig, DurableRunnerError, PROTOCOL, PROTOCOL_VERSION};
use super::{BootstrapTicket, DurableRunnerConfig, DurableRunnerError, PROTOCOL};
#[derive(Clone, Debug, PartialEq)]
pub struct CommandExecution {
@ -378,9 +378,25 @@ pub fn run_durable_runner<E: CommandExecutor>(
// mutually authenticated secure welcome exchanges it for a lease.
bootstrap_ticket.take();
}
let protocol_version = welcome.connection.protocol_version;
let upgrading_from_v1 =
protocol_version >= 2 && state.last_connection_protocol_version == Some(1);
if let Some(acked_source_seq) = welcome.acked_source_seq {
state.apply_ack(acked_source_seq)?;
// A v2 welcome immediately following a v1 connection reports the
// shared cumulative cursor, including redacted placeholders. Do
// not interpret that cursor as acknowledgement of their native v2
// payloads; those are restored below with fresh source sequences.
let acknowledgement_protocol = if upgrading_from_v1 {
1
} else {
protocol_version
};
state.apply_ack(acked_source_seq, acknowledgement_protocol)?;
}
if protocol_version >= 2 {
state.restore_v2_replay_events(&config)?;
}
state.last_connection_protocol_version = Some(protocol_version);
let connection = welcome.connection;
if state.pending_terminal_delivery.is_some() {
return reconcile_pending_terminal_delivery(
@ -414,7 +430,9 @@ pub fn run_durable_runner<E: CommandExecutor>(
)?;
}
lifecycle_after_reply = lifecycle_after_reply.merge(lifecycle);
if let Err(error) = transport.send_json(&command_result_envelope(&state, &result)) {
if let Err(error) =
transport.send_json(&command_result_envelope(&state, &result, protocol_version))
{
if lifecycle.durable_state().is_some() {
return stop_after_terminal_result_delivery_failure(
&mut state,
@ -459,7 +477,12 @@ pub fn run_durable_runner<E: CommandExecutor>(
continue;
}
if !disconnected {
if let Err(error) = send_outbox(&mut transport, &state, &mut sent_source_seq) {
if let Err(error) = send_outbox(
&mut transport,
&state,
&mut sent_source_seq,
protocol_version,
) {
state.record_diagnostic(
"outbox delivery failed; unacknowledged suffix remains durable",
);
@ -495,7 +518,12 @@ pub fn run_durable_runner<E: CommandExecutor>(
break;
}
poll_executor_events(&mut state, &store, &config, &mut executor)?;
if let Err(error) = send_outbox(&mut transport, &state, &mut sent_source_seq) {
if let Err(error) = send_outbox(
&mut transport,
&state,
&mut sent_source_seq,
connection.protocol_version,
) {
disconnected_since.get_or_insert_with(Instant::now);
state.record_diagnostic(error.to_string());
state.reconnect_count = state.reconnect_count.saturating_add(1);
@ -538,7 +566,7 @@ pub fn run_durable_runner<E: CommandExecutor>(
.pointer("/payload/ackedSourceSeq")
.and_then(Value::as_u64)
.ok_or_else(|| DurableRunnerError::invalid("ACK cursor is required"))?;
state.apply_ack(acked)?;
state.apply_ack(acked, connection.protocol_version)?;
store.save(&state)?;
}
Some("command") => {
@ -560,9 +588,11 @@ pub fn run_durable_runner<E: CommandExecutor>(
&result,
)?;
}
if let Err(error) =
transport.send_json(&command_result_envelope(&state, &result))
{
if let Err(error) = transport.send_json(&command_result_envelope(
&state,
&result,
connection.protocol_version,
)) {
if lifecycle.durable_state().is_some() {
return stop_after_terminal_result_delivery_failure(
&mut state,
@ -605,7 +635,12 @@ pub fn run_durable_runner<E: CommandExecutor>(
disconnected_since = Some(Instant::now());
break;
}
if let Err(error) = send_outbox(&mut transport, &state, &mut sent_source_seq) {
if let Err(error) = send_outbox(
&mut transport,
&state,
&mut sent_source_seq,
connection.protocol_version,
) {
state.record_diagnostic(
"outbox delivery failed; unacknowledged suffix remains durable",
);
@ -782,7 +817,7 @@ fn wait_for_terminal_result_ack(
.pointer("/payload/ackedSourceSeq")
.and_then(Value::as_u64)
.ok_or_else(|| DurableRunnerError::invalid("ACK cursor is required"))?;
state.apply_ack(acked)?;
state.apply_ack(acked, connection.protocol_version)?;
store.save(state)?;
}
Some("ping") => transport.send_json(&control_envelope(
@ -836,7 +871,11 @@ fn reconcile_pending_terminal_delivery<E: CommandExecutor>(
"pending terminal command did not replay its durable lifecycle",
));
}
if let Err(error) = transport.send_json(&command_result_envelope(state, &result)) {
if let Err(error) = transport.send_json(&command_result_envelope(
state,
&result,
connection.protocol_version,
)) {
return stop_after_terminal_result_delivery_failure(state, store, executor, error);
}
if let Err(error) =
@ -855,7 +894,12 @@ fn reconcile_pending_terminal_delivery<E: CommandExecutor>(
}
let mut sent_source_seq = state.acked_source_seq;
if let Err(error) = send_outbox(transport, state, &mut sent_source_seq) {
if let Err(error) = send_outbox(
transport,
state,
&mut sent_source_seq,
connection.protocol_version,
) {
state.record_diagnostic("outbox delivery failed after terminal result reconciliation");
store.save(state)?;
let _ = executor.shutdown();
@ -974,25 +1018,56 @@ fn process_command<E: CommandExecutor>(
Ok((result, CommandLifecycle::for_terminal(command)))
}
fn event_envelope_for_protocol(event: &StoredOutboxEvent, protocol_version: u64) -> Value {
let mut envelope = event.envelope.clone();
if protocol_version == 1 && envelope.pointer("/payload/schemaVersion") == Some(&json!(2)) {
// Preserve the source sequence on a v1 connection so its cumulative
// ACK can advance past an event family that only exists in PRP v2.
// Never copy the v2 payload because it can include a goal objective.
envelope["payload"]["schema"] = json!("paperclip.prp.event.v1");
envelope["payload"]["eventType"] = json!("runner.diagnostic");
envelope["payload"]["schemaVersion"] = json!(1);
envelope["payload"]["payload"] = json!({
"reasonCode": "event_requires_prp_v2",
"originalEventType": event.event_type,
});
}
envelope["version"] = json!(protocol_version);
envelope
}
fn send_outbox(
transport: &mut AuthenticatedTransport,
state: &DurableState,
sent_source_seq: &mut u64,
protocol_version: u64,
) -> Result<(), DurableRunnerError> {
for event in &state.outbox {
if event.source_seq <= *sent_source_seq {
continue;
}
transport.send_json(&event.envelope)?;
let envelope = event_envelope_for_protocol(event, protocol_version);
transport.send_json(&envelope)?;
*sent_source_seq = event.source_seq;
}
Ok(())
}
fn command_result_envelope(state: &DurableState, result: &StoredCommandResult) -> Value {
fn command_result_envelope(
state: &DurableState,
result: &StoredCommandResult,
protocol_version: u64,
) -> Value {
let mut payload = json!(result);
// "indeterminate" is an internal crash-recovery journal state. On the
// wire it is a failed command with the preserved execution_indeterminate
// reason so the controller can settle the command and continue replay.
if result.status == "indeterminate" {
payload["status"] = json!("failed");
}
json!({
"protocol": PROTOCOL,
"version": PROTOCOL_VERSION,
"version": protocol_version,
"kind": "command_result",
"runnerInstanceId": state.runner_instance_id,
"environmentLeaseId": state.environment_lease_id,
@ -1000,7 +1075,7 @@ fn command_result_envelope(state: &DurableState, result: &StoredCommandResult) -
"normalizedSessionId": state.normalized_session_id,
"turnId": state.turn_id,
"itemId": state.item_id,
"payload": result,
"payload": payload,
})
}
@ -1012,7 +1087,7 @@ fn control_envelope(
) -> Value {
json!({
"protocol": PROTOCOL,
"version": PROTOCOL_VERSION,
"version": connection.protocol_version,
"kind": kind,
"runnerInstanceId": state.runner_instance_id,
"environmentLeaseId": state.environment_lease_id,
@ -1155,6 +1230,73 @@ mod tests {
}
}
#[test]
fn indeterminate_recovery_result_is_a_failed_wire_result() {
let state = DurableState::new(&config(PathBuf::from("unused")));
let result = StoredCommandResult {
command_id: "command_1".to_owned(),
controller_seq: 1,
command_type: "semantic_tool.result".to_owned(),
status: "indeterminate".to_owned(),
result: json!({"code": "execution_indeterminate"}),
};
let envelope = command_result_envelope(&state, &result, 2);
assert_eq!(envelope.pointer("/payload/status"), Some(&json!("failed")));
assert_eq!(
envelope.pointer("/payload/result/code"),
Some(&json!("execution_indeterminate")),
);
}
#[test]
fn v2_outbox_event_becomes_redacted_v1_diagnostic_without_losing_sequence() {
let config = config(PathBuf::from("unused"));
let mut state = DurableState::new(&config);
state
.enqueue_event(
&config,
"session.goal.updated",
EventPriority::P1,
json!({
"goal": {
"objective": "sensitive operator objective",
"status": "active"
}
}),
)
.unwrap();
let event = &state.outbox[0];
let downgraded = event_envelope_for_protocol(event, 1);
assert_eq!(downgraded["version"], json!(1));
assert_eq!(
downgraded.pointer("/payload/sourceSeq"),
Some(&json!(event.source_seq)),
);
assert_eq!(
downgraded.pointer("/payload/schema"),
Some(&json!("paperclip.prp.event.v1")),
);
assert_eq!(
downgraded.pointer("/payload/eventType"),
Some(&json!("runner.diagnostic")),
);
assert_eq!(
downgraded.pointer("/payload/payload/originalEventType"),
Some(&json!("session.goal.updated")),
);
assert!(!downgraded
.to_string()
.contains("sensitive operator objective"));
let native = event_envelope_for_protocol(event, 2);
assert_eq!(native["version"], json!(2));
assert_eq!(
native.pointer("/payload/eventType"),
Some(&json!("session.goal.updated")),
);
}
#[test]
fn warm_run_attachment_rotates_only_the_run_authority() {
let directory = std::env::temp_dir().join(format!(
@ -1452,7 +1594,7 @@ mod tests {
assert_eq!(state.outbox.len(), 1);
assert_eq!(executor.events.len(), 1);
state
.apply_ack(1)
.apply_ack(1, 2)
.expect("controller ACK removes the durable outbox copy");
store.save(&state).unwrap();

View File

@ -19,6 +19,7 @@ const MAX_RECENT_COMMANDS: usize = 128;
const MAX_DIAGNOSTICS: usize = 32;
const MAX_COMMAND_RESULT_BYTES: usize = 64 * 1024;
const MAX_EXECUTOR_EVENT_RECEIPTS: usize = 256;
const MAX_V2_REPLAY_EVENTS: usize = 2;
const STATE_OVERHEAD_BYTES: usize = 16 * 1024 * 1024;
const TEMP_FILE_ATTEMPTS: usize = 32;
@ -40,6 +41,16 @@ impl EventPriority {
}
}
fn v2_replay_key(event_type: &str) -> Option<&'static str> {
match event_type {
"session.capabilities.updated" => Some("session.capabilities"),
"session.goal.snapshot" | "session.goal.updated" | "session.goal.cleared" => {
Some("session.goal")
}
_ => None,
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Command {
@ -59,9 +70,18 @@ pub struct Command {
impl Command {
pub fn validate(&self) -> Result<(), DurableRunnerError> {
if self.schema != "paperclip.prp.command.v1" {
let schema_version = match self.schema.as_str() {
"paperclip.prp.command.v1" => 1,
"paperclip.prp.command.v2" => 2,
_ => {
return Err(DurableRunnerError::invalid(
"command requires a supported paperclip.prp.command schema",
));
}
};
if schema_version == 1 && self.command_type.starts_with("session.goal.") {
return Err(DurableRunnerError::invalid(
"command requires the paperclip.prp.command.v1 schema",
"session goal commands require the paperclip.prp.command.v2 schema",
));
}
if self.command_id.is_empty()
@ -122,10 +142,13 @@ impl Command {
| "runner.drain"
| "runner.suspend"
| "runner.shutdown"
| "session.goal.get"
| "session.goal.set"
| "session.goal.clear"
) {
return Err(DurableRunnerError::invalid(
"command type is not supported by PRP v1",
));
return Err(DurableRunnerError::invalid(format!(
"command type is not supported by PRP v{schema_version}"
)));
}
Ok(())
}
@ -141,6 +164,15 @@ pub struct StoredOutboxEvent {
pub byte_size: usize,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredV2ReplayEvent {
pub source_seq: u64,
pub priority: u8,
pub event_type: String,
pub payload: Value,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredCommandResult {
@ -201,6 +233,10 @@ pub struct DurableState {
pub(crate) pending_terminal_delivery: Option<PendingTerminalDelivery>,
#[serde(default)]
executor_event_receipts: BTreeMap<String, ExecutorEventReceipt>,
#[serde(default)]
v2_replay_events: BTreeMap<String, StoredV2ReplayEvent>,
#[serde(default)]
pub last_connection_protocol_version: Option<u64>,
pub diagnostics: Vec<String>,
pub backpressure: bool,
pub recoverable_failure: Option<String>,
@ -230,6 +266,8 @@ impl DurableState {
processed_command_fingerprints: BTreeMap::new(),
pending_terminal_delivery: None,
executor_event_receipts: BTreeMap::new(),
v2_replay_events: BTreeMap::new(),
last_connection_protocol_version: None,
diagnostics: Vec::new(),
backpressure: false,
recoverable_failure: None,
@ -387,6 +425,17 @@ impl DurableState {
let source_seq = self.next_source_seq;
let emitted_at = current_timestamp()?;
let schema_version = if matches!(
event_type.as_str(),
"session.capabilities.updated"
| "session.goal.snapshot"
| "session.goal.updated"
| "session.goal.cleared"
) {
2
} else {
1
};
let envelope = json!({
"protocol": PROTOCOL,
"version": PROTOCOL_VERSION,
@ -398,7 +447,7 @@ impl DurableState {
"turnId": self.turn_id,
"itemId": self.item_id,
"payload": {
"schema": "paperclip.prp.event.v1",
"schema": format!("paperclip.prp.event.v{schema_version}"),
"sourceEventId": source_event_id,
"sourceSeq": source_seq,
"sourceInstanceId": self.runner_instance_id,
@ -408,7 +457,7 @@ impl DurableState {
"turnId": self.turn_id,
"itemId": self.item_id,
"eventType": event_type,
"schemaVersion": 1,
"schemaVersion": schema_version,
"priority": priority.number(),
"emittedAt": emitted_at,
"payload": sanitized_payload,
@ -450,15 +499,30 @@ impl DurableState {
self.outbox.push(StoredOutboxEvent {
source_seq,
priority: priority.number(),
event_type,
event_type: event_type.clone(),
envelope,
byte_size,
});
if let Some(replay_key) = v2_replay_key(&event_type) {
self.v2_replay_events.insert(
replay_key.to_owned(),
StoredV2ReplayEvent {
source_seq,
priority: priority.number(),
event_type,
payload: sanitize_value(&payload),
},
);
}
self.peak_outbox_bytes = self.peak_outbox_bytes.max(projected);
Ok(source_seq)
}
pub fn apply_ack(&mut self, acked_source_seq: u64) -> Result<(), DurableRunnerError> {
pub fn apply_ack(
&mut self,
acked_source_seq: u64,
protocol_version: u64,
) -> Result<(), DurableRunnerError> {
if acked_source_seq < self.acked_source_seq {
return Err(DurableRunnerError::invalid(
"cumulative ACK cannot move behind the durable cursor",
@ -472,6 +536,10 @@ impl DurableState {
self.acked_source_seq = acked_source_seq;
self.outbox
.retain(|event| event.source_seq > acked_source_seq);
if protocol_version >= 2 {
self.v2_replay_events
.retain(|_, event| event.source_seq > acked_source_seq);
}
if self.backpressure
&& self.outbox_bytes() < self.max_outbox_bytes.saturating_sub(self.p0_reserve_bytes)
{
@ -483,6 +551,32 @@ impl DurableState {
Ok(())
}
pub(crate) fn restore_v2_replay_events(
&mut self,
config: &DurableRunnerConfig,
) -> Result<(), DurableRunnerError> {
let replay = self
.v2_replay_events
.values()
.filter(|event| event.source_seq <= self.acked_source_seq)
.cloned()
.collect::<Vec<_>>();
for event in replay {
let priority = match event.priority {
0 => EventPriority::P0,
1 => EventPriority::P1,
2 => EventPriority::P2,
_ => {
return Err(DurableRunnerError::invalid(
"v2 replay event priority is invalid",
));
}
};
self.enqueue_event(config, event.event_type, priority, event.payload)?;
}
Ok(())
}
pub fn begin_command(
&mut self,
command: &Command,
@ -977,6 +1071,21 @@ fn validate_binding(
&& receipt.source_seq <= state.highest_source_seq()
&& executor_receipt_sequences.insert(receipt.source_seq)
});
let v2_replay_events_are_valid = state.v2_replay_events.len() <= MAX_V2_REPLAY_EVENTS
&& state.v2_replay_events.iter().all(|(key, replay)| {
v2_replay_key(&replay.event_type) == Some(key.as_str())
&& replay.source_seq > 0
&& replay.source_seq <= state.highest_source_seq()
&& replay.priority <= 2
&& replay.payload.is_object()
&& (replay.source_seq <= state.acked_source_seq
|| state.outbox.iter().any(|event| {
event.source_seq == replay.source_seq
&& event.priority == replay.priority
&& event.event_type == replay.event_type
&& event.envelope.pointer("/payload/payload") == Some(&replay.payload)
}))
});
let pending_terminal_delivery_is_valid =
state
.pending_terminal_delivery
@ -1026,6 +1135,10 @@ fn validate_binding(
|| !command_cursors_are_valid
|| !command_fingerprints_are_valid
|| !executor_event_receipts_are_valid
|| !v2_replay_events_are_valid
|| state
.last_connection_protocol_version
.is_some_and(|version| !(1..=PROTOCOL_VERSION).contains(&version))
|| !pending_terminal_delivery_is_valid
{
return Err(DurableRunnerError::invalid(
@ -1129,6 +1242,12 @@ pub(crate) fn create_private_temporary_file(
fn sensitive_key(key: &str, value: &Value) -> bool {
let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
if normalized == "tokenbudgetcontrol" {
return !value.is_boolean();
}
if normalized == "tokenbudget" {
return !value.is_number() && !value.is_null();
}
if matches!(
normalized.as_str(),
"inputtokens"
@ -1143,6 +1262,7 @@ fn sensitive_key(key: &str, value: &Value) -> bool {
| "totaltokens"
| "pretokens"
| "posttokens"
| "tokensused"
) {
return !value.is_number();
}
@ -1747,6 +1867,15 @@ mod tests {
}
}
#[test]
fn session_goal_commands_require_and_accept_the_v2_schema() {
let mut goal = command("goal-command", 1);
goal.command_type = "session.goal.set".to_owned();
assert!(goal.validate().is_err());
goal.schema = "paperclip.prp.command.v2".to_owned();
assert!(goal.validate().is_ok());
}
fn temporary_directory(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"paperclip-runner-durable-{label}-{}",
@ -1764,10 +1893,70 @@ mod tests {
state
.enqueue_event(&config, "runner.reconnected", EventPriority::P1, json!({}))
.unwrap();
state.apply_ack(1).unwrap();
state.apply_ack(1, 2).unwrap();
assert_eq!(state.outbox.len(), 1);
assert!(state.apply_ack(0).is_err());
assert!(state.apply_ack(3).is_err());
assert!(state.apply_ack(0, 2).is_err());
assert!(state.apply_ack(3, 2).is_err());
}
#[test]
fn session_goal_events_use_the_prp_v2_event_schema() {
let config = config(PathBuf::from("unused"));
let mut state = DurableState::new(&config);
state
.enqueue_event(
&config,
"session.goal.snapshot",
EventPriority::P0,
json!({"goal": null}),
)
.unwrap();
assert_eq!(
state.outbox[0].envelope.pointer("/payload/schema"),
Some(&json!("paperclip.prp.event.v2")),
);
assert_eq!(
state.outbox[0].envelope.pointer("/payload/schemaVersion"),
Some(&json!(2)),
);
}
#[test]
fn v1_acknowledgement_replays_latest_goal_state_for_v2() {
let config = config(PathBuf::from("unused"));
let mut state = DurableState::new(&config);
state
.enqueue_event(
&config,
"session.goal.updated",
EventPriority::P0,
json!({"goal": {"objective": "durable objective", "status": "active"}}),
)
.unwrap();
state.apply_ack(1, 1).unwrap();
assert!(state.outbox.is_empty());
assert_eq!(state.v2_replay_events["session.goal"].source_seq, 1);
validate_binding(&state, &config, false).unwrap();
state.restore_v2_replay_events(&config).unwrap();
assert_eq!(state.outbox.len(), 1);
assert_eq!(state.outbox[0].source_seq, 2);
assert_eq!(
state.outbox[0].envelope.pointer("/payload/eventType"),
Some(&json!("session.goal.updated")),
);
assert_eq!(
state.outbox[0]
.envelope
.pointer("/payload/payload/goal/objective"),
Some(&json!("durable objective")),
);
state.apply_ack(2, 2).unwrap();
assert!(state.outbox.is_empty());
assert!(state.v2_replay_events.is_empty());
validate_binding(&state, &config, false).unwrap();
}
#[test]
@ -2149,6 +2338,20 @@ mod tests {
);
}
#[test]
fn goal_usage_fields_are_not_mistaken_for_credentials() {
let sanitized = sanitize_value(&json!({
"tokenBudgetControl": true,
"tokenBudget": 4096,
"tokensUsed": 128,
"accessToken": "secret-value",
}));
assert_eq!(sanitized["tokenBudgetControl"], json!(true));
assert_eq!(sanitized["tokenBudget"], json!(4096));
assert_eq!(sanitized["tokensUsed"], json!(128));
assert_eq!(sanitized["accessToken"], json!("[REDACTED]"));
}
#[test]
fn diagnostic_redaction_preserves_context_and_removes_only_secret_values() {
assert_eq!(

View File

@ -20,7 +20,8 @@ use tungstenite::{accept_hdr_with_config, client_tls_with_config, Connector, Mes
use super::state::{open_private_regular_file, Command, DurableState};
use super::{
BootstrapTicket, DurableRunnerConfig, DurableRunnerError, Secret, PROTOCOL, PROTOCOL_VERSION,
BootstrapTicket, DurableRunnerConfig, DurableRunnerError, Secret, PROTOCOL,
PROTOCOL_MIN_VERSION, PROTOCOL_VERSION,
};
const SECURE_FRAME_SCHEMA: &str = "paperclip.runner.secure-frame.v1";
@ -719,6 +720,7 @@ impl LeaseCredential {
#[derive(Clone, Debug)]
pub(crate) struct ConnectionMetadata {
pub(crate) protocol_version: u64,
pub(crate) connection_id: String,
pub(crate) lease_id: String,
pub(crate) expires_at_unix_ms: u64,
@ -990,7 +992,7 @@ impl AuthenticatedTransport {
"credentialId": credential.credential_id,
"credentialKind": credential_kind,
"clientNonce": client_nonce,
"protocolMin": PROTOCOL_VERSION,
"protocolMin": PROTOCOL_MIN_VERSION,
"protocolMax": PROTOCOL_VERSION,
"runnerInstanceId": state.runner_instance_id,
"environmentLeaseId": state.environment_lease_id,
@ -1014,7 +1016,6 @@ impl AuthenticatedTransport {
let challenge_deadline = socket.configure_auth_timeouts(connect_deadline)?;
let challenge_value =
receive_plain_until(&mut socket, config.max_frame_bytes, challenge_deadline)?;
validate_envelope_kind(&challenge_value, "auth_challenge")?;
let challenge: AuthChallenge = serde_json::from_value(
challenge_value
.get("payload")
@ -1024,6 +1025,11 @@ impl AuthenticatedTransport {
.map_err(|error| {
DurableRunnerError::invalid(format!("invalid auth challenge: {error}"))
})?;
validate_envelope_kind_version(
&challenge_value,
"auth_challenge",
challenge.selected_version,
)?;
validate_challenge(
&challenge,
state,
@ -1049,7 +1055,7 @@ impl AuthenticatedTransport {
&mut socket,
&json!({
"protocol": PROTOCOL,
"version": PROTOCOL_VERSION,
"version": challenge.selected_version,
"kind": "auth_response",
"payload": {
"credentialId": credential.credential_id,
@ -1090,8 +1096,13 @@ impl AuthenticatedTransport {
let mut welcome_value = transport
.receive_json_until(Some(welcome_deadline))?
.ok_or_else(|| DurableRunnerError::invalid("authenticated welcome timed out"))?;
let welcome =
validate_welcome(&mut welcome_value, state, credential_kind, expected_lease)?;
let welcome = validate_welcome(
&mut welcome_value,
state,
credential_kind,
expected_lease,
challenge.selected_version,
)?;
// Authentication can wait longer for control-plane validation, but
// the steady-state runner loop must return to provider polling
// promptly when no control message is available.
@ -1217,7 +1228,7 @@ fn validate_challenge(
}
}
if challenge.server_nonce.is_empty()
|| challenge.selected_version != PROTOCOL_VERSION
|| !(PROTOCOL_MIN_VERSION..=PROTOCOL_VERSION).contains(&challenge.selected_version)
|| challenge.credential_expires_at_unix_ms <= current_unix_ms()?
{
return Err(DurableRunnerError::invalid(
@ -1299,15 +1310,16 @@ fn validate_welcome(
state: &DurableState,
credential_kind: &str,
expected_lease: Option<&LeaseCredential>,
selected_version: u64,
) -> Result<Welcome, DurableRunnerError> {
validate_control_identity(value, state, None)?;
validate_envelope_kind(value, "welcome")?;
validate_control_identity_version(value, state, None, selected_version)?;
validate_envelope_kind_version(value, "welcome", selected_version)?;
let connection_id = required_string(value, "connectionId")?.to_owned();
let connection_lease_id = required_string(value, "connectionLeaseId")?.to_owned();
let payload = value
.get_mut("payload")
.ok_or_else(|| DurableRunnerError::invalid("welcome payload is required"))?;
if payload.get("selectedVersion").and_then(Value::as_u64) != Some(PROTOCOL_VERSION)
if payload.get("selectedVersion").and_then(Value::as_u64) != Some(selected_version)
|| payload.get("connectionLeaseId").and_then(Value::as_str)
!= Some(connection_lease_id.as_str())
{
@ -1373,6 +1385,7 @@ fn validate_welcome(
.unwrap_or_default();
Ok(Welcome {
connection: ConnectionMetadata {
protocol_version: selected_version,
connection_id,
lease_id: connection_lease_id,
expires_at_unix_ms,
@ -1388,9 +1401,21 @@ pub(crate) fn validate_control_identity(
value: &Value,
state: &DurableState,
connection: Option<&ConnectionMetadata>,
) -> Result<(), DurableRunnerError> {
let protocol_version = connection
.map(|connection| connection.protocol_version)
.unwrap_or(PROTOCOL_VERSION);
validate_control_identity_version(value, state, connection, protocol_version)
}
fn validate_control_identity_version(
value: &Value,
state: &DurableState,
connection: Option<&ConnectionMetadata>,
protocol_version: u64,
) -> Result<(), DurableRunnerError> {
if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL)
|| value.get("version").and_then(Value::as_u64) != Some(PROTOCOL_VERSION)
|| value.get("version").and_then(Value::as_u64) != Some(protocol_version)
{
return Err(DurableRunnerError::invalid(
"control envelope protocol identity is invalid",
@ -1423,13 +1448,17 @@ pub(crate) fn validate_control_identity(
Ok(())
}
fn validate_envelope_kind(value: &Value, kind: &str) -> Result<(), DurableRunnerError> {
fn validate_envelope_kind_version(
value: &Value,
kind: &str,
protocol_version: u64,
) -> Result<(), DurableRunnerError> {
if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL)
|| value.get("version").and_then(Value::as_u64) != Some(PROTOCOL_VERSION)
|| value.get("version").and_then(Value::as_u64) != Some(protocol_version)
|| value.get("kind").and_then(Value::as_str) != Some(kind)
{
return Err(DurableRunnerError::invalid(format!(
"expected a PRP v1 {kind} envelope"
"expected a PRP v{protocol_version} {kind} envelope"
)));
}
Ok(())
@ -2195,6 +2224,7 @@ mod tests {
let state = test_state(&config);
let mut envelope = control(&state, "connection_1", "ack", json!({"ackedSourceSeq": 0}));
let connection = ConnectionMetadata {
protocol_version: PROTOCOL_VERSION,
connection_id: "connection_1".to_owned(),
lease_id: "lease_1".to_owned(),
expires_at_unix_ms: current_unix_ms().unwrap() + 60_000,

View File

@ -37,6 +37,9 @@ pub enum GeneratedAcpxSidecarCommand {
ToolResolve,
SessionRead,
SessionSnapshot,
SessionGoalGet,
SessionGoalSet,
SessionGoalClear,
SessionSuspend,
SessionClose,
}
@ -54,6 +57,9 @@ impl GeneratedAcpxSidecarCommand {
Self::ToolResolve => "tool.resolve",
Self::SessionRead => "session.read",
Self::SessionSnapshot => "session.snapshot",
Self::SessionGoalGet => "session.goal.get",
Self::SessionGoalSet => "session.goal.set",
Self::SessionGoalClear => "session.goal.clear",
Self::SessionSuspend => "session.suspend",
Self::SessionClose => "session.close",
}
@ -76,4 +82,6 @@ pub enum GeneratedAcpxSidecarEventType {
RuntimeProcess,
#[serde(rename = "runtime.diagnostic")]
RuntimeDiagnostic,
#[serde(rename = "runtime.goal")]
RuntimeGoal,
}

View File

@ -432,7 +432,14 @@ fn normalize_provider_notification(
}
}
fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<NormalizedProviderEvent> {
fn terminal_events(
state: &CodexProviderState,
event_type: &str,
goal_status: Option<&str>,
) -> Vec<NormalizedProviderEvent> {
if goal_status == Some("active") {
return Vec::new();
}
let Some(contract) = state.completion_contract.as_ref() else {
return Vec::new();
};
@ -440,13 +447,20 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
// bounded controller finalizer may interrupt the provider after the result
// proposal. That provider terminal closes the exact turn; it does not
// revoke the already-authoritative semantic outcome.
let succeeded =
event_type == "turn.completed" || state.active_provider_result_fingerprint.is_some();
let succeeded = goal_status == Some("complete")
|| (goal_status.is_none()
&& (event_type == "turn.completed"
|| state.active_provider_result_fingerprint.is_some()));
let cancelled = matches!(event_type, "turn.cancelled" | "turn.interrupted");
let disposition = state
.active_provider_result_disposition
.as_deref()
.unwrap_or(if succeeded { "done" } else { "needs_review" });
let disposition = match goal_status {
Some("blocked") => "blocked",
Some("paused" | "limited" | "usage_limited" | "budget_limited") => "yielded",
Some("complete") => "done",
_ => state
.active_provider_result_disposition
.as_deref()
.unwrap_or(if succeeded { "done" } else { "needs_review" }),
};
let provider = state.config.provider.as_str();
let provider_name = if provider == "opencode" {
"OpenCode"
@ -483,7 +497,13 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
"objectiveSatisfied": succeeded,
"criteria": criteria,
"remainingWork": if succeeded { Vec::<Value>::new() } else { vec![json!({
"description": format!("Review the stopped {provider_name} run and continue the task."),
"description": if disposition == "yielded" {
"Resume the durable Codex goal when execution can continue.".to_owned()
} else if disposition == "blocked" {
"Resolve the blocker before resuming the durable Codex goal.".to_owned()
} else {
format!("Review the stopped {provider_name} run and continue the task.")
},
"blocksCompletion": true,
})] },
},
@ -509,7 +529,7 @@ fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<Normaliz
"schema": "paperclip.prp.terminal.v1",
"provider": provider,
"turnTerminalState": turn_terminal_state,
"runTerminalState": if succeeded { "succeeded" } else if cancelled { "cancelled" } else { "failed" },
"runTerminalState": if succeeded { "succeeded" } else if cancelled || disposition == "yielded" { "cancelled" } else { "failed" },
"reportedWorkDisposition": disposition,
});
let mut events = Vec::new();
@ -557,6 +577,192 @@ fn relabel_provider_event(
event
}
fn default_goal_revision() -> u64 {
0
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SessionGoalCapability {
availability: String,
actions: Vec<String>,
autonomous_updates: bool,
persistent_across_resume: bool,
max_objective_chars: u64,
token_budget_control: bool,
usage_reporting: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
}
impl SessionGoalCapability {
fn codex_available() -> Self {
Self {
availability: "available".to_owned(),
actions: vec![
"set".to_owned(),
"pause".to_owned(),
"resume".to_owned(),
"clear".to_owned(),
],
autonomous_updates: true,
persistent_across_resume: true,
max_objective_chars: 4_000,
token_budget_control: true,
usage_reporting: true,
reason_code: None,
reason: None,
}
}
fn unavailable(availability: &str, reason_code: &str) -> Self {
Self {
availability: availability.to_owned(),
actions: Vec::new(),
autonomous_updates: false,
persistent_across_resume: false,
max_objective_chars: 4_000,
token_budget_control: false,
usage_reporting: false,
reason_code: Some(reason_code.to_owned()),
reason: Some(
match availability {
"policy_disabled" => "Session goals are disabled by the Codex provider policy.",
_ => "This Codex app-server does not expose session goals.",
}
.to_owned(),
),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SessionGoalSnapshot {
objective: String,
status: String,
token_budget: Option<u64>,
tokens_used: u64,
elapsed_seconds: u64,
iterations: u64,
#[serde(default)]
last_reason: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
#[serde(default)]
completed_at: Option<String>,
working_now: bool,
}
fn normalize_goal_status(status: &str) -> Option<&'static str> {
match status {
"active" => Some("active"),
"paused" => Some("paused"),
"blocked" => Some("blocked"),
"limited" => Some("limited"),
"usageLimited" | "usage_limited" => Some("usage_limited"),
"budgetLimited" | "budget_limited" => Some("budget_limited"),
"complete" => Some("complete"),
_ => None,
}
}
fn codex_goal_status(status: &str) -> Option<&'static str> {
match status {
"active" => Some("active"),
"paused" => Some("paused"),
"blocked" => Some("blocked"),
"usage_limited" => Some("usageLimited"),
"budget_limited" => Some("budgetLimited"),
"complete" => Some("complete"),
_ => None,
}
}
fn goal_timestamp(value: Option<&Value>) -> Option<String> {
use aws_smithy_types::{date_time::Format, DateTime};
let value = value?;
if let Some(text) = value.as_str() {
return DateTime::from_str(text, Format::DateTime)
.ok()?
.fmt(Format::DateTime)
.ok();
}
let timestamp = value.as_i64().filter(|value| *value > 0)?;
let date = if timestamp < 10_000_000_000 {
DateTime::from_secs(timestamp)
} else {
DateTime::from_millis(timestamp)
};
date.fmt(Format::DateTime).ok()
}
fn normalize_codex_goal(value: &Value, working_now: bool) -> Option<SessionGoalSnapshot> {
let goal = value.get("goal").unwrap_or(value);
if goal.is_null() {
return None;
}
let objective = goal.get("objective")?.as_str()?.trim();
let status = normalize_goal_status(goal.get("status")?.as_str()?)?;
if objective.is_empty() || objective.chars().count() > 4_000 {
return None;
}
Some(SessionGoalSnapshot {
objective: objective.to_owned(),
status: status.to_owned(),
token_budget: goal.get("tokenBudget").and_then(Value::as_u64),
tokens_used: goal.get("tokensUsed").and_then(Value::as_u64).unwrap_or(0),
elapsed_seconds: goal
.get("timeUsedSeconds")
.or_else(|| goal.get("elapsedSeconds"))
.and_then(Value::as_u64)
.unwrap_or(0),
iterations: goal.get("iterations").and_then(Value::as_u64).unwrap_or(0),
last_reason: goal
.get("lastReason")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(1_000).collect()),
created_at: goal_timestamp(goal.get("createdAt")),
updated_at: goal_timestamp(goal.get("updatedAt")),
completed_at: goal_timestamp(goal.get("completedAt").or_else(|| {
(status == "complete")
.then(|| goal.get("updatedAt"))
.flatten()
})),
working_now,
})
}
fn goal_event_payload(
goal: Option<&SessionGoalSnapshot>,
capability: Option<&SessionGoalCapability>,
revision: u64,
) -> Value {
json!({
"schema": "paperclip.session_goal.snapshot.v1",
"goal": goal,
"sessionGoals": capability,
"workingNow": goal.is_some_and(|goal| goal.working_now),
"revision": revision,
})
}
fn goal_control_event_payload(
goal: Option<&SessionGoalSnapshot>,
capability: Option<&SessionGoalCapability>,
revision: u64,
request_id: Option<&str>,
) -> Value {
let mut payload = goal_event_payload(goal, capability, revision);
if let Some(request_id) = request_id.filter(|value| !value.is_empty()) {
payload["requestId"] = json!(request_id);
}
payload
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CodexProviderState {
@ -608,6 +814,12 @@ struct CodexProviderState {
active_provider_result_disposition: Option<String>,
last_agent_message: Option<String>,
#[serde(default)]
goal_capability: Option<SessionGoalCapability>,
#[serde(default)]
goal: Option<SessionGoalSnapshot>,
#[serde(default = "default_goal_revision")]
goal_revision: u64,
#[serde(default)]
pending_events: VecDeque<PolledEvent>,
#[serde(default)]
queued_events: VecDeque<PolledEvent>,
@ -679,6 +891,9 @@ impl CodexProviderState {
active_provider_result_fingerprint: None,
active_provider_result_disposition: None,
last_agent_message: None,
goal_capability: None,
goal: None,
goal_revision: default_goal_revision(),
pending_events: VecDeque::new(),
queued_events: VecDeque::new(),
next_provider_event_seq: initial_provider_event_seq(),
@ -729,6 +944,11 @@ impl CodexProviderState {
.last_agent_message
.as_ref()
.is_some_and(|value| value.is_empty() || value.len() > 1_000_000)
|| self.goal.as_ref().is_some_and(|goal| {
goal.objective.trim().is_empty()
|| goal.objective.chars().count() > 4_000
|| normalize_goal_status(&goal.status).is_none()
})
|| (self.thread_id.is_none()
&& (self.provider_session_id.is_some()
|| self.active_provider_turn_id.is_some()
@ -1501,7 +1721,11 @@ impl CodexCommandExecutor {
// accepted this exact turn's semantic result before the
// runner stopped observing provider output. Resume
// finalization without inventing another provider turn.
state.extend_terminal_events(terminal_events(state, "turn.completed"))?;
state.extend_terminal_events(terminal_events(
state,
"turn.completed",
state.goal.as_ref().map(|goal| goal.status.as_str()),
))?;
} else {
// A turn that disappeared while runnerd was offline has no
// trustworthy success notification to replay. Terminate it
@ -1517,7 +1741,11 @@ impl CodexCommandExecutor {
"providerTerminalObserved": false,
}),
})?;
state.extend_terminal_events(terminal_events(state, "turn.failed"))?;
state.extend_terminal_events(terminal_events(
state,
"turn.failed",
state.goal.as_ref().map(|goal| goal.status.as_str()),
))?;
}
} else {
state.push_event(reconciled)?;
@ -1907,32 +2135,75 @@ impl CodexCommandExecutor {
.as_ref()
.and_then(|state| state.thread_id.as_ref())
.is_some();
let (thread_id, provider_session_id, process_id) = {
let (thread_id, provider_session_id, process_id, active_provider_turn_id, goal_probe) = {
let provider = self.ensure_provider()?;
(
provider.thread_id().to_owned(),
provider.provider_session_id().map(str::to_owned),
provider.process_id(),
provider.active_provider_turn_id().map(str::to_owned),
provider.get_goal(),
)
};
let (provider_name, driver, provider_version) = {
let (goal_capability, goal) = match goal_probe {
Ok(snapshot) => (
SessionGoalCapability::codex_available(),
normalize_codex_goal(&snapshot, active_provider_turn_id.is_some()),
),
Err(error) => {
let message = error.to_string().to_ascii_lowercase();
if message.contains("policy")
|| message.contains("disabled")
|| message.contains("feature")
{
(
SessionGoalCapability::unavailable(
"policy_disabled",
"codex_goal_policy_disabled",
),
None,
)
} else {
(
SessionGoalCapability::unavailable(
"unsupported",
if message.contains("-32601") || message.contains("unknown method") {
"codex_goal_unknown_method"
} else {
"codex_goal_probe_failed"
},
),
None,
)
}
}
};
let (provider_name, driver, provider_version, goal_revision) = {
let state = self
.state
.as_mut()
.expect("Codex state exists after provider start");
state.thread_id = Some(thread_id.clone());
state.provider_session_id = provider_session_id.clone();
state.active_provider_turn_id = None;
state.active_provider_turn_id = active_provider_turn_id.clone();
state.receipt_limit_diagnostic_emitted = false;
state.receipt_limit_interrupt_pending = false;
state.receipt_limit_interrupt_accepted = false;
state.receipt_limit_interrupt_attempts = 0;
state.receipt_limit_interrupt_deadline_unix_ms = None;
state.lifecycle = "session_open".to_owned();
state.lifecycle = if active_provider_turn_id.is_some() {
"turn_active".to_owned()
} else {
"session_open".to_owned()
};
state.goal_capability = Some(goal_capability.clone());
state.goal = goal.clone();
state.goal_revision = state.goal_revision.saturating_add(1);
(
state.config.provider.clone(),
state.config.driver.clone(),
state.config.provider_version.clone(),
state.goal_revision,
)
};
self.save_state()?;
@ -1945,21 +2216,33 @@ impl CodexCommandExecutor {
"providerSessionId": thread_id,
"processId": process_id,
}),
events: vec![(
if resumed {
"session.resumed"
} else {
"session.started"
}
.to_owned(),
EventPriority::P0,
json!({
"provider": provider_name,
"providerSessionId": thread_id,
"providerAccountSessionId": provider_session_id,
"processId": process_id,
}),
)],
events: vec![
(
if resumed {
"session.resumed"
} else {
"session.started"
}
.to_owned(),
EventPriority::P0,
json!({
"provider": provider_name,
"providerSessionId": thread_id,
"providerAccountSessionId": provider_session_id,
"processId": process_id,
}),
),
(
"session.capabilities.updated".to_owned(),
EventPriority::P0,
json!({"sessionGoals": goal_capability}),
),
(
"session.goal.snapshot".to_owned(),
EventPriority::P0,
goal_event_payload(goal.as_ref(), Some(&goal_capability), goal_revision),
),
],
})
}
@ -2406,6 +2689,183 @@ impl CodexCommandExecutor {
Ok(CommandExecution::result(json!({"status": "steered"})))
}
fn ensure_goal_available(&self) -> Result<(), DurableRunnerError> {
if self
.state
.as_ref()
.and_then(|state| state.goal_capability.as_ref())
.is_some_and(|capability| capability.availability == "available")
{
Ok(())
} else {
Err(DurableRunnerError::invalid(
"Codex session goals are unavailable for this provider session",
))
}
}
fn get_goal(&mut self) -> Result<CommandExecution, DurableRunnerError> {
self.restore_provider_if_needed()?;
self.ensure_goal_available()?;
let (snapshot, working_now) = {
let provider = self.ensure_provider()?;
let snapshot = provider.get_goal().map_err(|error| {
DurableRunnerError::invalid(format!("Codex thread/goal/get failed: {error}"))
})?;
(snapshot, provider.active_provider_turn_id().is_some())
};
let goal = normalize_codex_goal(&snapshot, working_now);
let (capability, revision) = {
let state = self
.state
.as_mut()
.expect("Codex state exists while reading its goal");
state.goal = goal.clone();
state.goal_revision = state.goal_revision.saturating_add(1);
(state.goal_capability.clone(), state.goal_revision)
};
self.save_state()?;
let payload = goal_event_payload(goal.as_ref(), capability.as_ref(), revision);
Ok(CommandExecution {
result: payload.clone(),
events: vec![(
"session.goal.snapshot".to_owned(),
EventPriority::P0,
payload,
)],
})
}
fn set_goal(&mut self, payload: &Value) -> Result<CommandExecution, DurableRunnerError> {
self.restore_provider_if_needed()?;
self.ensure_goal_available()?;
let objective = payload
.get("objective")
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty() && value.chars().count() <= 4_000)
.ok_or_else(|| {
DurableRunnerError::invalid(
"session.goal.set objective must be nonblank and at most 4000 characters",
)
})
})
.transpose()?;
let status = payload
.get("status")
.map(|value| {
let status = value.as_str().ok_or_else(|| {
DurableRunnerError::invalid("session.goal.set status must be a string")
})?;
codex_goal_status(status).ok_or_else(|| {
DurableRunnerError::invalid("session.goal.set status is not supported by Codex")
})
})
.transpose()?;
let token_budget = if let Some(value) = payload.get("tokenBudget") {
if value.is_null() {
Some(None)
} else {
Some(Some(value.as_u64().filter(|value| *value > 0).ok_or_else(
|| {
DurableRunnerError::invalid(
"session.goal.set tokenBudget must be null or a positive integer",
)
},
)?))
}
} else {
None
};
if objective.is_none() && status.is_none() && token_budget.is_none() {
return Err(DurableRunnerError::invalid(
"session.goal.set requires objective, status, or tokenBudget",
));
}
let (result, working_now) = {
let provider = self.ensure_provider()?;
let result = provider
.set_goal(objective, status, token_budget)
.map_err(|error| {
DurableRunnerError::invalid(format!("Codex thread/goal/set failed: {error}"))
})?;
(result, provider.active_provider_turn_id().is_some())
};
let snapshot = if normalize_codex_goal(&result, working_now).is_some() {
result
} else {
self.ensure_provider()?.get_goal().map_err(|error| {
DurableRunnerError::invalid(format!(
"Codex thread/goal/get after set failed: {error}"
))
})?
};
let goal = normalize_codex_goal(&snapshot, working_now).ok_or_else(|| {
DurableRunnerError::invalid("Codex thread/goal/set omitted a valid goal snapshot")
})?;
let (capability, revision) = {
let state = self
.state
.as_mut()
.expect("Codex state exists while setting its goal");
state.goal = Some(goal.clone());
state.goal_revision = state.goal_revision.saturating_add(1);
(state.goal_capability.clone(), state.goal_revision)
};
self.save_state()?;
let event = goal_control_event_payload(
Some(&goal),
capability.as_ref(),
revision,
payload.get("requestId").and_then(Value::as_str),
);
Ok(CommandExecution {
result: json!({"status": "accepted", "snapshot": event}),
events: vec![("session.goal.updated".to_owned(), EventPriority::P0, event)],
})
}
fn clear_goal(&mut self, payload: &Value) -> Result<CommandExecution, DurableRunnerError> {
self.restore_provider_if_needed()?;
self.ensure_goal_available()?;
let result = self.ensure_provider()?.clear_goal().map_err(|error| {
DurableRunnerError::invalid(format!("Codex thread/goal/clear failed: {error}"))
})?;
let (capability, revision, working_now) = {
let working_now = self
.provider
.as_ref()
.is_some_and(|provider| provider.active_provider_turn_id().is_some());
let state = self
.state
.as_mut()
.expect("Codex state exists while clearing its goal");
state.goal = None;
state.goal_revision = state.goal_revision.saturating_add(1);
(
state.goal_capability.clone(),
state.goal_revision,
working_now,
)
};
self.save_state()?;
let event = json!({
"schema": "paperclip.session_goal.snapshot.v1",
"goal": Value::Null,
"sessionGoals": capability,
"workingNow": working_now,
"revision": revision,
"cleared": result.get("cleared").and_then(Value::as_bool).unwrap_or(true),
"requestId": payload.get("requestId").and_then(Value::as_str),
});
Ok(CommandExecution {
result: json!({"status": "accepted", "snapshot": event}),
events: vec![("session.goal.cleared".to_owned(), EventPriority::P0, event)],
})
}
fn resolve_request(&mut self, payload: &Value) -> Result<CommandExecution, DurableRunnerError> {
let request_id = payload
.get("requestId")
@ -2647,7 +3107,7 @@ impl CodexCommandExecutor {
"providerShutdownFailed": provider_shutdown_failed,
}),
})?;
let terminal = terminal_events(state, terminal_event_type);
let terminal = terminal_events(state, terminal_event_type, None);
state.extend_terminal_events(terminal)?;
self.save_state()
}
@ -2930,6 +3390,9 @@ impl CodexCommandExecutor {
"sessionId": state.provider_session_id,
"providerAccountSessionId": state.provider_session_id,
"activeProviderTurnId": state.active_provider_turn_id,
"sessionGoals": state.goal_capability,
"goal": state.goal,
"goalRevision": state.goal_revision,
"warmAttachReady": warm_attach_ready,
"warmAttachBlockers": warm_attach_blockers,
"cwd": state.config.cwd,
@ -3011,6 +3474,27 @@ impl CodexCommandExecutor {
None
};
let terminal_event_type = normalized_terminal_type.map(str::to_owned);
let goal_reconciliation = if terminal_event_type.is_some()
&& self
.state
.as_ref()
.and_then(|state| state.goal_capability.as_ref())
.is_some_and(|capability| capability.availability == "available")
{
Some(
self.provider
.as_mut()
.expect("provider remains present during goal reconciliation")
.get_goal()
.map_err(|error| error.to_string()),
)
} else {
None
};
let working_now = self
.provider
.as_ref()
.is_some_and(|provider| provider.active_provider_turn_id().is_some());
let identity = self.event_identity.clone();
let state = self
.state
@ -3033,6 +3517,19 @@ impl CodexCommandExecutor {
)
})?;
state.reconcile_active_provider_turn(Some(provider_turn_id));
if let Some(goal) = state.goal.as_mut() {
goal.working_now = true;
state.goal_revision = state.goal_revision.saturating_add(1);
state.push_event(NormalizedProviderEvent {
event_type: "session.goal.updated".to_owned(),
priority: EventPriority::P0,
payload: goal_event_payload(
state.goal.as_ref(),
state.goal_capability.as_ref(),
state.goal_revision,
),
})?;
}
}
let normalized = normalize_provider_notification(state, &method, &params)?;
let normalized_event_count = normalized.len();
@ -3083,6 +3580,80 @@ impl CodexCommandExecutor {
state.receipt_limit_interrupt_deadline_unix_ms = None;
state.ambiguous_turn_start_pending = false;
state.lifecycle = "session_open".to_owned();
if let Some(goal) = state.goal.as_mut() {
goal.working_now = false;
state.goal_revision = state.goal_revision.saturating_add(1);
state.push_event(NormalizedProviderEvent {
event_type: "session.goal.updated".to_owned(),
priority: EventPriority::P0,
payload: goal_event_payload(
state.goal.as_ref(),
state.goal_capability.as_ref(),
state.goal_revision,
),
})?;
}
}
if method == "thread/goal/updated" {
state.goal = normalize_codex_goal(&params, working_now);
state.goal_revision = state.goal_revision.saturating_add(1);
state.push_event(NormalizedProviderEvent {
event_type: "session.goal.updated".to_owned(),
priority: EventPriority::P0,
payload: goal_event_payload(
state.goal.as_ref(),
state.goal_capability.as_ref(),
state.goal_revision,
),
})?;
} else if method == "thread/goal/cleared" {
state.goal = None;
state.goal_revision = state.goal_revision.saturating_add(1);
state.push_event(NormalizedProviderEvent {
event_type: "session.goal.cleared".to_owned(),
priority: EventPriority::P0,
payload: goal_event_payload(
None,
state.goal_capability.as_ref(),
state.goal_revision,
),
})?;
}
if let Some(reconciliation) = goal_reconciliation {
match reconciliation {
Ok(snapshot) => {
let next_goal = normalize_codex_goal(&snapshot, false);
if next_goal != state.goal {
state.goal = next_goal;
state.goal_revision = state.goal_revision.saturating_add(1);
state.push_event(NormalizedProviderEvent {
event_type: "session.goal.snapshot".to_owned(),
priority: EventPriority::P0,
payload: goal_event_payload(
state.goal.as_ref(),
state.goal_capability.as_ref(),
state.goal_revision,
),
})?;
}
}
Err(_) => {
state.push_event(NormalizedProviderEvent {
event_type: "provider.notice.recorded".to_owned(),
priority: EventPriority::P0,
payload: json!({
"schema": "paperclip.provider.notice.v1",
"noticeId": "codex-goal-reconcile-failed",
"severity": "warning",
"category": "goal_reconciliation",
"scope": "session",
"recoverable": true,
"userActionable": false,
"summary": "Codex goal state could not be reconciled after the turn; Paperclip retained the last durable snapshot.",
}),
})?;
}
}
}
let trace_first_event_sequence = state.next_provider_event_seq;
if terminal_event_type.is_some() {
@ -3096,7 +3667,12 @@ impl CodexCommandExecutor {
}
let trace_last_event_sequence = state.next_provider_event_seq;
if let Some(event_type) = terminal_event_type {
state.extend_terminal_events(terminal_events(state, &event_type))?;
let goal_status = state.goal.as_ref().map(|goal| goal.status.as_str());
state.extend_terminal_events(terminal_events(
state,
&event_type,
goal_status,
))?;
}
let trace_emitted_event_ids = identity
.as_ref()
@ -3254,6 +3830,9 @@ impl CommandExecutor for CodexCommandExecutor {
"session.open" => self.open_session(),
"turn.start" => self.start_turn(&command.payload),
"turn.steer" => self.steer_turn(&command.payload),
"session.goal.get" => self.get_goal(),
"session.goal.set" => self.set_goal(&command.payload),
"session.goal.clear" => self.clear_goal(&command.payload),
"turn.interrupt" | "run.cancel" => self.interrupt_turn(&command.command_type),
"turn.stop" => self.stop_turn_for_suspension(&command.command_type),
"request.resolve" => self.resolve_request(&command.payload),
@ -3432,7 +4011,7 @@ mod tests {
normalize_provider_notification(&mut state, "paperclip/runResult", &params).unwrap();
let replay_events =
normalize_provider_notification(&mut state, "paperclip/runResult", &params).unwrap();
let terminal = terminal_events(&state, "turn.completed");
let terminal = terminal_events(&state, "turn.completed", None);
assert_eq!(result_events.len(), 1);
assert_eq!(result_events[0].event_type, "run.result.proposed");
@ -3453,7 +4032,7 @@ mod tests {
result.as_object_mut().unwrap().remove("artifacts");
admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap();
let terminal = terminal_events(&state, "turn.completed");
let terminal = terminal_events(&state, "turn.completed", None);
assert_eq!(terminal.len(), 1);
assert_eq!(terminal[0].event_type, "run.terminal");
@ -3470,7 +4049,7 @@ mod tests {
let result = valid_opencode_result();
admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap();
let terminal = terminal_events(&state, "turn.interrupted");
let terminal = terminal_events(&state, "turn.interrupted", None);
assert_eq!(terminal.len(), 1);
assert_eq!(terminal[0].event_type, "run.terminal");
@ -3620,7 +4199,7 @@ mod tests {
);
state.last_agent_message = None;
let events = terminal_events(&state, "turn.completed");
let events = terminal_events(&state, "turn.completed", None);
assert_eq!(
events[0].payload["summary"],
@ -3634,6 +4213,51 @@ mod tests {
assert!(!events[0].payload.to_string().contains("Codex"));
}
#[test]
fn goal_timestamps_normalize_seconds_milliseconds_and_iso() {
for value in [
json!(1_788_825_600),
json!(1_788_825_600_000_i64),
json!("2026-09-08T00:00:00.000Z"),
] {
assert_eq!(
goal_timestamp(Some(&value)).as_deref(),
Some("2026-09-08T00:00:00Z")
);
}
assert_eq!(goal_timestamp(Some(&json!("invalid"))), None);
assert_eq!(goal_timestamp(Some(&Value::Null)), None);
}
#[test]
fn goal_snapshot_serializes_required_nullable_fields() {
let goal = SessionGoalSnapshot {
objective: "Finish the durable goal.".to_owned(),
status: "active".to_owned(),
token_budget: None,
tokens_used: 0,
elapsed_seconds: 0,
iterations: 0,
last_reason: None,
created_at: None,
updated_at: None,
completed_at: None,
working_now: true,
};
let payload = goal_event_payload(Some(&goal), None, 1);
for path in [
"/goal/tokenBudget",
"/goal/lastReason",
"/goal/createdAt",
"/goal/updatedAt",
"/goal/completedAt",
] {
assert_eq!(payload.pointer(path), Some(&Value::Null), "{path}");
}
}
#[test]
fn rejects_inconsistent_provider_state() {
let state = CodexProviderState {
@ -3676,6 +4300,9 @@ mod tests {
active_provider_result_fingerprint: None,
active_provider_result_disposition: None,
last_agent_message: None,
goal_capability: None,
goal: None,
goal_revision: default_goal_revision(),
pending_events: VecDeque::new(),
queued_events: VecDeque::new(),
next_provider_event_seq: initial_provider_event_seq(),
@ -3750,7 +4377,7 @@ mod tests {
ProviderToolBridge::default(),
);
state.last_agent_message = Some("Finished the requested work.".to_owned());
let events = terminal_events(&state, "turn.completed");
let events = terminal_events(&state, "turn.completed", None);
assert_eq!(events[0].event_type, "run.result.proposed");
assert_eq!(events[0].payload["summary"], "Finished the requested work.");
assert_eq!(events[1].event_type, "run.terminal");

View File

@ -277,6 +277,18 @@ pub fn project_acpx_state_event(
"details": details,
}),
),
AcpxProviderStateEvent::Goal(details) => {
let goal = details.get("goal").cloned().unwrap_or(Value::Null);
one(
if goal.is_null() {
"session.goal.cleared"
} else {
"session.goal.updated"
},
EventPriority::P0,
details.clone(),
)
}
AcpxProviderStateEvent::Diagnostic { code, message } => one(
"harness.diagnostic",
EventPriority::P1,

View File

@ -5,7 +5,8 @@ use std::fmt::{self, Display, Formatter};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const PRP_PROTOCOL_VERSION: u64 = 1;
pub const PRP_PROTOCOL_MIN_VERSION: u64 = 1;
pub const PRP_PROTOCOL_VERSION: u64 = 2;
const PRP_FIXTURE_VERSION: u64 = 1;
const PRP_FIXTURE_SCHEMA: &str = "paperclip.prp.fixture.v1";
const PRP_EVENT_SCHEMA: &str = "paperclip.prp.event.v1";
@ -90,9 +91,9 @@ impl Error for ReplayError {}
pub fn reduce_replay_fixture(input: &str) -> Result<ReplayParitySummary, ReplayError> {
let fixture: ReplayFixture = serde_json::from_str(input)
.map_err(|error| ReplayError::invalid(format!("fixture must be valid JSON: {error}")))?;
if fixture.protocol_version != PRP_PROTOCOL_VERSION {
if !(PRP_PROTOCOL_MIN_VERSION..=PRP_PROTOCOL_VERSION).contains(&fixture.protocol_version) {
return Err(ReplayError::invalid(format!(
"unsupported required protocolVersion {}; expected {PRP_PROTOCOL_VERSION}",
"unsupported required protocolVersion {}; expected {PRP_PROTOCOL_MIN_VERSION}-{PRP_PROTOCOL_VERSION}",
fixture.protocol_version
)));
}
@ -340,10 +341,10 @@ mod tests {
let error = reduce_replay_fixture(include_str!(
"../../../../protocol/fixtures/replay/unsupported-required-version.json"
))
.expect_err("PRP v2 fixture must fail closed");
.expect_err("PRP v3 fixture must fail closed");
assert!(error
.to_string()
.contains("unsupported required protocolVersion 2"));
.contains("unsupported required protocolVersion 3"));
}
#[test]

View File

@ -5,6 +5,7 @@ use paperclip_runner_core::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderSession, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
};
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarTransportConfig;
use paperclip_runner_core::generated_acpx_sidecar_contract::GeneratedAcpxSidecarCommand as GoalCommand;
use paperclip_runner_core::provider_bridge::{
authorized_tool_catalog_digest, AuthorizedTool, AuthorizedToolSet,
};
@ -76,6 +77,43 @@ fn start_error(config: &AcpxProviderSessionConfig) -> String {
}
}
#[test]
fn controls_goals_and_observes_updates_without_an_active_prompt() {
let mut session = AcpxProviderSession::start(&config("goals")).unwrap();
let initial = session
.goal_control(GoalCommand::SessionGoalGet, json!({}))
.unwrap();
assert_eq!(
initial["sessionGoals"]["actions"],
json!(["set", "pause", "resume", "clear"])
);
assert!(initial["goal"].is_null());
for status in ["active", "paused", "active"] {
let result = session
.goal_control(
GoalCommand::SessionGoalSet,
json!({"objective":"Verify the durable goal", "status":status}),
)
.unwrap();
assert_eq!(result["goal"]["status"], status);
assert!(session.state().active_turn_id().is_none());
let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
assert!(
!events.is_empty(),
"out-of-prompt goal update must not disappear"
);
}
let cleared = session
.goal_control(GoalCommand::SessionGoalClear, json!({}))
.unwrap();
assert!(cleared["goal"].is_null());
assert!(session
.poll_event(Duration::from_secs(1))
.unwrap()
.is_some());
session.shutdown("goal test complete").unwrap();
}
#[test]
fn bootstraps_a_codex_session_and_confirms_run_identity() {
let mut session = AcpxProviderSession::start(&config("bootstrap")).unwrap();

View File

@ -366,6 +366,72 @@ fn codex_transport_buffers_notifications_while_waiting_for_responses() {
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn codex_goal_autostart_binds_the_provider_turn_authority() {
let directory = temporary_directory("goal-autostart");
let config = provider_config(&directory, &["--goal-autostart"]);
let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider");
let result = provider
.set_goal(Some("Complete the fake goal."), Some("active"), None)
.expect("activate the provider goal");
assert_eq!(result.pointer("/goal/status"), Some(&json!("active")));
let started = wait_for_notification(&mut provider, "turn/started");
assert_eq!(
started.pointer("/turn/id"),
Some(&json!("provider-goal-turn-1")),
);
assert_eq!(
provider.active_provider_turn_id(),
Some("provider-goal-turn-1"),
);
provider.shutdown().expect("stop provider");
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn codex_objective_only_paused_goal_edit_does_not_arm_turn_reconciliation() {
let directory = temporary_directory("goal-paused-edit");
let config = provider_config(&directory, &["--goal-autostart"]);
let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider");
provider
.set_goal(Some("Initial objective."), Some("paused"), None)
.expect("create paused provider goal");
let result = provider
.set_goal(Some("Edited while paused."), None, None)
.expect("edit paused provider goal");
assert_eq!(result.pointer("/goal/status"), Some(&json!("paused")));
provider
.start_turn("A normal turn remains safe.", &config.cwd)
.expect("start normal provider turn after paused edit");
provider.shutdown().expect("stop provider");
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn rejected_codex_goal_activation_restores_turn_reconciliation() {
let directory = temporary_directory("goal-set-rejected");
let config = provider_config(&directory, &["--reject-goal-set"]);
let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider");
let error = provider
.set_goal(Some("Rejected objective."), Some("active"), None)
.expect_err("reject provider goal activation");
assert!(error.to_string().contains("goal set rejected"));
provider
.start_turn("A normal turn remains safe.", &config.cwd)
.expect("start normal provider turn after rejected goal");
provider.shutdown().expect("stop provider");
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn codex_dynamic_tool_round_trips_through_the_provider_boundary() {
let directory = temporary_directory("dynamic-tool");

View File

@ -15,7 +15,7 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
const CODEX_ACPX_DIGEST: &str =
"sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400";
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00";
fn temporary_directory(label: &str) -> PathBuf {
let nonce = SystemTime::now()

View File

@ -310,7 +310,7 @@ try {
claude:
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
codex:
"sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400",
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
},
artifacts: {
nodeCommand: {

View File

@ -15,7 +15,9 @@ const validatorsOutputPath = resolve(
const schemaNames = [
"identity",
"capabilities",
"capabilities-v2",
"command",
"command-v2",
"provider-descriptor",
"provider-event",
"workspace-diff",
@ -30,6 +32,8 @@ const schemaNames = [
"request",
"result",
"event",
"event-v2",
"session-goal",
"fixture",
];
@ -61,6 +65,7 @@ for (const { value } of schemas) ajv.addSchema(value);
const standaloneValidators = standaloneCode(ajv, {
fixtureValidator: schemaByName.fixture.$id,
eventValidator: schemaByName.event.$id,
eventV2Validator: schemaByName["event-v2"].$id,
resultValidator: schemaByName.result.$id,
});
const ucs2RuntimePattern = /const (func\d+) = require\("ajv\/dist\/runtime\/ucs2length"\)\.default;/;

View File

@ -6,9 +6,11 @@ import { relative, resolve, sep } from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
export const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
export const PRP_SCHEMA_ID_PREFIX = "https://paperclip.dev/schemas/prp/v1/";
export const PRP_SCHEMA_ID_PREFIX = "https://paperclip.dev/schemas/prp/";
export const PRP_V1_SCHEMA_ID_PREFIX = `${PRP_SCHEMA_ID_PREFIX}v1/`;
export const SUPPORTED_FIXTURE_VERSION = 1;
export const SUPPORTED_PROTOCOL_VERSION = 1;
export const MIN_SUPPORTED_PROTOCOL_VERSION = 1;
export const SUPPORTED_PROTOCOL_VERSION = 2;
export const SUPPORTED_EVENT_SCHEMA_VERSION = 1;
function contractError(code, detail) {
@ -114,7 +116,13 @@ export function compileProtocolValidators(schemaRecords) {
for (const record of schemaRecords) ajv.addSchema(record.value);
const get = (name) => {
const id = `${PRP_SCHEMA_ID_PREFIX}${name}.schema.json`;
const id = `${PRP_V1_SCHEMA_ID_PREFIX}${name}.schema.json`;
const validator = ajv.getSchema(id);
if (validator === undefined) throw contractError("missing_schema_validator", id);
return validator;
};
const getVersioned = (version, name) => {
const id = `${PRP_SCHEMA_ID_PREFIX}v${version}/${name}.schema.json`;
const validator = ajv.getSchema(id);
if (validator === undefined) throw contractError("missing_schema_validator", id);
return validator;
@ -125,6 +133,9 @@ export function compileProtocolValidators(schemaRecords) {
fixture: get("fixture"),
providerDescriptor: get("provider-descriptor"),
questionAdapterFixture: get("question-adapter-fixture"),
capabilitiesV2: getVersioned(2, "capabilities"),
commandV2: getVersioned(2, "command"),
eventV2: getVersioned(2, "event"),
};
}
@ -153,16 +164,27 @@ function requireVersion(value, expected, name) {
}
}
function requireSupportedProtocolVersion(value) {
if (!Number.isInteger(value) || value < MIN_SUPPORTED_PROTOCOL_VERSION || value > SUPPORTED_PROTOCOL_VERSION) {
throw contractError(
"unsupported_required_version",
`protocolVersion=${String(value)}; supported=${MIN_SUPPORTED_PROTOCOL_VERSION}-${SUPPORTED_PROTOCOL_VERSION}`,
);
}
}
export function assertReplayFixtureCompatibility(fixture) {
requireSchema(fixture, "paperclip.prp.fixture.v1", "fixture");
requireVersion(fixture.fixtureVersion, SUPPORTED_FIXTURE_VERSION, "fixtureVersion");
requireVersion(fixture.protocolVersion, SUPPORTED_PROTOCOL_VERSION, "protocolVersion");
requireSupportedProtocolVersion(fixture.protocolVersion);
requireSchema(fixture.identity, "paperclip.prp.identity.v1", "identity");
requireSchema(fixture.capabilities, "paperclip.prp.capabilities.v1", "capabilities");
if (!Array.isArray(fixture.commands)) throw contractError("invalid_fixture", "commands must be an array");
for (const [index, command] of fixture.commands.entries()) {
requireSchema(command, "paperclip.prp.command.v1", `commands[${index}]`);
if (command?.schema !== "paperclip.prp.command.v1" && command?.schema !== "paperclip.prp.command.v2") {
throw contractError("unsupported_required_schema", `commands[${index}] requires ${String(command?.schema)}`);
}
}
if (!Array.isArray(fixture.events) || fixture.events.length === 0) {

View File

@ -142,6 +142,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
dispositionOnlyRecoveryTurnId:
snapshot.dispositionOnlyRecoveryTurnId ?? null,
pendingRuntimeRequests: snapshot.pendingRuntimeRequests ?? [],
goal: snapshot.goal ?? null,
lineage: snapshot.lineage ?? [],
};
const recoveryOptions: HarnessSessionRecoveryOptions = {
@ -672,6 +673,13 @@ class HarnessNativeSession implements NativeSession {
return this.#session.handoffRuntimeRequest(input);
}
goal(input: Parameters<NonNullable<HarnessSession["goal"]>>[0]) {
if (this.#session.goal === undefined) {
throw new Error("native_session_goal_unavailable");
}
return this.#session.goal(input);
}
async result() {
if (this.#explicitlyCancelled) return null;
const snapshot = await this.#session.snapshot();
@ -727,6 +735,7 @@ class HarnessNativeSession implements NativeSession {
dispositionOnlyRecoveryTurnId:
snapshot.dispositionOnlyRecoveryTurnId ?? null,
pendingRuntimeRequests: snapshot.pendingRuntimeRequests ?? [],
goal: snapshot.goal ?? null,
lineage: snapshot.lineage ?? [],
};
}

View File

@ -104,7 +104,7 @@ function acpxExecution(
agent === "pi" ? "0.84.2" : agent === "codex" ? "0.153.4" : "0.3.263",
commandDigest:
agent === "codex"
? "sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400"
? "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00"
: agent === "pi"
? "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f"
: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",

View File

@ -23,6 +23,7 @@ import {
type NormalizedAcpForm,
} from "../drivers/acpx/acp-question-adapter.js";
import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js";
import { acpxGoalProjection } from "../drivers/acpx/session-goals.js";
import { acpxProviderSessionIdentity } from "../drivers/acpx/recovery-identity.js";
import {
resolveQualifiedAcpxProfile,
@ -79,6 +80,10 @@ import {
const MAX_PENDING_TOOLS = 512;
const MAX_PENDING_INPUTS = 16;
let goalSourceRevision = 0;
function observedGoalProjection(...args: Parameters<typeof acpxGoalProjection>) {
return { ...acpxGoalProjection(...args), providerRevision: ++goalSourceRevision };
}
function reportRetainedAcpxCleanupFailure(
input: AcpxRetainedCleanupFailure,
@ -262,6 +267,11 @@ async function dispatch(
tools: params.tools,
handler: waitForTool,
},
onGoalUpdate: (goal) => {
// Admission can emit a snapshot before the verified host is assigned.
// session.goal.get publishes that snapshot after session.open instead.
if (host) emit("runtime.goal", observedGoalProjection(host.goalCapability(), goal, turnId !== null));
},
},
{
retainAdmissionCleanup: retainFailedAdmissionCleanup,
@ -433,6 +443,33 @@ async function dispatch(
pendingInputCount: inputs.size,
};
}
if (request.command === "session.goal.get") {
const activeHost = requireHost();
return observedGoalProjection(activeHost.goalCapability(), activeHost.goalSnapshot(), turnId !== null);
}
if (request.command === "session.goal.set") {
const activeHost = requireHost();
if (Object.prototype.hasOwnProperty.call(request.params, "tokenBudget")) {
throw new Error("The negotiated ACP goal extension does not support token budget control");
}
const objective = text(request.params.objective).trim();
const status = text(request.params.status).trim();
const action = objective
? "set"
: status === "paused"
? "pause"
: status === "active"
? "resume"
: null;
if (!action) throw new Error("session.goal.set requires an objective or active/paused status");
const goal = await activeHost.controlGoal(action, objective || undefined);
return observedGoalProjection(activeHost.goalCapability(), goal, turnId !== null);
}
if (request.command === "session.goal.clear") {
const activeHost = requireHost();
await activeHost.controlGoal("clear");
return observedGoalProjection(activeHost.goalCapability(), null, turnId !== null);
}
if (request.command === "session.suspend") {
if (turnId || tools.size > 0 || inputs.size > 0) {
throw new Error("ACPX session is not at a safe suspension point");

View File

@ -89,7 +89,7 @@ export interface DurableRecoveryRunnerState extends DurableRecoveryIdentity {
}
export interface DurableRecoveryCoreCommand {
schema: "paperclip.prp.command.v1";
schema: "paperclip.prp.command.v1" | "paperclip.prp.command.v2";
commandId: string;
controllerSeq: number;
type: string;

View File

@ -401,13 +401,7 @@ export function harnessRuntimeInputExpiredOutcome(
export interface HarnessThreadGoal {
threadId: string;
objective: string;
status:
| "active"
| "paused"
| "blocked"
| "usageLimited"
| "budgetLimited"
| "complete";
status: "active" | "paused" | "blocked" | "limited" | "usageLimited" | "budgetLimited" | "complete";
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
@ -416,9 +410,15 @@ export interface HarnessThreadGoal {
}
export type HarnessGoalOperation =
| { action: "get" }
| { action: "set"; objective: string; tokenBudget?: number | null }
| { action: "pause" | "resume" | "clear" };
| { action: "get"; requestId?: string }
| {
action: "set";
objective: string;
tokenBudget?: number | null;
status?: HarnessThreadGoal["status"];
requestId?: string;
}
| { action: "pause" | "resume" | "clear"; requestId?: string };
export interface HarnessThreadLineageEntry {
threadId: string;

View File

@ -229,6 +229,8 @@ export interface NativeSessionExecutionResult {
nativeEventCount: number;
highestContiguousSourceSeq: number;
usage: Record<string, unknown> | null;
/** The active durable goal reached a safe turn boundary for run rollover. */
goalRolloverRequired?: boolean;
}
export class NativeExecutionInputError extends Error {

View File

@ -12,6 +12,8 @@ import type {
HarnessRuntimeRequest,
HarnessRuntimeRequestHandoff,
HarnessRuntimeRequestResolution,
HarnessGoalOperation,
HarnessThreadGoal,
HarnessThreadLineageEntry,
NativeRuntimeContextCapabilities,
PersistedHarnessProviderIdentity,
@ -62,6 +64,7 @@ export interface PersistedNativeSession {
dispositionOnlyRecoveryConsumed?: boolean;
dispositionOnlyRecoveryTurnId?: string | null;
pendingRuntimeRequests?: HarnessRuntimeRequest[];
goal?: HarnessThreadGoal | null;
lineage?: HarnessThreadLineageEntry[];
}
@ -128,6 +131,7 @@ export interface NativeSession {
*/
signal: AbortSignal;
}): HarnessRuntimeRequestHandoff;
goal?(input: HarnessGoalOperation): Promise<HarnessThreadGoal | null>;
result(): Promise<{
result: PrpStructuredRunResult;
terminal: PrpTerminalState;

View File

@ -41,7 +41,8 @@ import {
} from "./prp-transport-types.js";
const protocol = "paperclip.runner";
const protocolVersion = 1;
const protocolMinVersion = 1;
const protocolVersion = 2;
const secureFrameSchema = "paperclip.runner.secure-frame.v1";
const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const coreStateSchema = "paperclip.runner.durable.control-plane-state.v1";
@ -68,6 +69,9 @@ const commandTypes = new Set([
"interaction.receipt",
"semantic_tool.result",
"session.snapshot",
"session.goal.get",
"session.goal.set",
"session.goal.clear",
"session.close",
"session.budget.increase",
"session.destroy",
@ -188,6 +192,7 @@ interface PendingChallenge {
serverProof: string;
clientNonce: string;
serverNonce: string;
selectedVersion: number;
}
interface SecureChannel {
@ -403,7 +408,8 @@ function isStoredCoreState(
!commands.every(
(command, index) =>
isRecord(command) &&
command.schema === "paperclip.prp.command.v1" &&
(command.schema === "paperclip.prp.command.v1" ||
command.schema === "paperclip.prp.command.v2") &&
typeof command.commandId === "string" &&
stableIdPattern.test(command.commandId) &&
command.commandId.length <= 160 &&
@ -1184,7 +1190,9 @@ export class DurablePrpControlPlane {
}
const controllerSeq = this.#store.state.commands.length + 1;
const command: DurableRecoveryCoreCommand = {
schema: "paperclip.prp.command.v1",
schema: type.startsWith("session.goal.")
? "paperclip.prp.command.v2"
: "paperclip.prp.command.v1",
commandId:
commandId ?? `command_prp_${controllerSeq.toString().padStart(8, "0")}`,
controllerSeq,
@ -1212,6 +1220,23 @@ export class DurablePrpControlPlane {
return command;
}
commandOutcome(commandId: string): {
status: DurableRecoveryCoreCommand["status"];
result: Record<string, unknown> | null;
} | null {
const command = this.#store.state.commands.find(
(candidate) => candidate.commandId === commandId,
);
if (!command) return null;
return {
status: command.status,
result:
command.result && typeof command.result === "object"
? structuredClone(command.result as Record<string, unknown>)
: null,
};
}
/** Attach one HTTP upgrade to this run-bound authority. */
handleUpgrade(
request: IncomingMessage,
@ -1295,9 +1320,18 @@ export class DurablePrpControlPlane {
connection.close();
return;
}
const envelopeVersion = envelope.version;
const expectedVersion =
connection.lease?.protocolVersion ??
connection.pendingChallenge?.selectedVersion ??
null;
if (
envelope.protocol !== protocol ||
envelope.version !== protocolVersion
!Number.isInteger(envelopeVersion) ||
(expectedVersion === null
? (envelopeVersion as number) < protocolMinVersion ||
(envelopeVersion as number) > protocolVersion
: envelopeVersion !== expectedVersion)
) {
connection.close();
return;
@ -1389,13 +1423,17 @@ export class DurablePrpControlPlane {
payload.itemId !== identity.itemId ||
payload.runnerVersion !== this.#expectedRunnerVersion ||
payload.runnerDigest !== this.#expectedRunnerDigest ||
payload.protocolMin !== 1 ||
payload.protocolMax !== 1 ||
!Number.isInteger(payload.protocolMin) ||
!Number.isInteger(payload.protocolMax) ||
(payload.protocolMin as number) > protocolVersion ||
(payload.protocolMax as number) < protocolMinVersion ||
(payload.protocolMin as number) > (payload.protocolMax as number) ||
(authorization.kind === "bootstrap" &&
(authorization.runnerVersion !== this.#expectedRunnerVersion ||
authorization.runnerDigest !== this.#expectedRunnerDigest)) ||
(authorization.kind === "lease" &&
authorization.protocolVersion !== protocolVersion)
(authorization.protocolVersion < (payload.protocolMin as number) ||
authorization.protocolVersion > (payload.protocolMax as number)))
) {
return null;
}
@ -1482,6 +1520,10 @@ export class DurablePrpControlPlane {
return;
}
const serverNonce = randomUUID();
const selectedVersion =
authorization.kind === "lease"
? authorization.protocolVersion
: Math.min(protocolVersion, payload.protocolMax as number);
const challengePayload: Record<string, unknown> = {
credentialId: authorization.credentialId,
credentialKind: authorization.kind,
@ -1495,7 +1537,7 @@ export class DurablePrpControlPlane {
itemId: payload.itemId,
runnerVersion: payload.runnerVersion,
runnerDigest: payload.runnerDigest,
selectedVersion: protocolVersion,
selectedVersion,
credentialLeaseId:
authorization.kind === "lease" ? authorization.leaseId : null,
credentialExpiresAt: authorization.expiresAt,
@ -1519,10 +1561,11 @@ export class DurablePrpControlPlane {
serverProof,
clientNonce: payload.clientNonce,
serverNonce,
selectedVersion,
};
connection.sendJson({
protocol,
version: protocolVersion,
version: selectedVersion,
kind: "auth_challenge",
payload: { ...challengePayload, serverProof },
});
@ -1581,7 +1624,7 @@ export class DurablePrpControlPlane {
authKeyDigest: `sha256:${material.authKey.toString("hex")}`,
leaseId: `connection_lease_${randomUUID()}`,
identity: structuredClone(this.#identity),
protocolVersion,
protocolVersion: pending.selectedVersion,
expiresAt: new Date(expiresAtUnixMs).toISOString(),
expiresAtUnixMs,
revocationEpoch: 0,
@ -1632,7 +1675,7 @@ export class DurablePrpControlPlane {
this.#store.save();
connection.sendJson({
protocol,
version: protocolVersion,
version: lease.protocolVersion,
envelopeId: `welcome_${this.#store.state.connectionCount}`,
kind: "welcome",
runnerInstanceId: this.#identity.runnerInstanceId,
@ -1645,7 +1688,7 @@ export class DurablePrpControlPlane {
connectionLeaseId: lease.leaseId,
sentAt: new Date().toISOString(),
payload: {
selectedVersion: 1,
selectedVersion: lease.protocolVersion,
heartbeatIntervalMs: 250,
connectionLeaseId: lease.leaseId,
...(leaseToken === null ? {} : { connectionLeaseToken: leaseToken }),
@ -1657,7 +1700,7 @@ export class DurablePrpControlPlane {
environmentLeaseId: this.#identity.environmentLeaseId,
runId: this.#identity.runId,
normalizedSessionId: this.#identity.normalizedSessionId,
protocolVersion,
protocolVersion: lease.protocolVersion,
},
maxFrameBytes,
maxBatchEvents: 100,
@ -1694,7 +1737,7 @@ export class DurablePrpControlPlane {
}
return {
protocol,
version: protocolVersion,
version: connection.lease.protocolVersion,
envelopeId,
kind,
runnerInstanceId: this.#identity.runnerInstanceId,

View File

@ -8,7 +8,7 @@ export interface DurableRecoveryIdentity {
}
export interface DurableRecoveryCoreCommand {
schema: "paperclip.prp.command.v1";
schema: "paperclip.prp.command.v1" | "paperclip.prp.command.v2";
commandId: string;
controllerSeq: number;
type: string;

View File

@ -14,6 +14,8 @@ import {
} from "acpx/runtime";
import type {
AcpxRuntimeGoalCapability,
AcpxRuntimeGoalSnapshot,
AcpxRuntimePort,
AcpxRuntimePortIdentity,
AcpxRuntimePortOpenOptions,
@ -57,6 +59,27 @@ const activeCodexRuntimeCleanupOwners = new Set<Promise<unknown>>();
// qualified runtime enough time to complete that local handshake.
const SESSION_HANDSHAKE_TIMEOUT_MS = 30_000;
interface GoalAwareAcpRuntime extends AcpRuntime {
requestExtension?(input: {
handle: AcpRuntimeHandle;
method: string;
params: Record<string, unknown>;
sessionMode?: "persistent" | "oneshot";
}): Promise<Record<string, unknown>>;
}
type GoalAwareAcpRuntimeOptions = AcpRuntimeOptions & {
onAgentInitialize?: (result: unknown) => void;
onSessionNotification?: (notification: unknown) => void;
};
interface AcpxRuntimeGoalState {
capability: AcpxRuntimeGoalCapability | null;
snapshot: AcpxRuntimeGoalSnapshot | null;
revision: number;
observedSnapshot: boolean;
}
class AcpxRuntimeCloseTimeoutError extends Error {
constructor() {
super("ACPX runtime close timed out");
@ -222,7 +245,27 @@ export async function openQualifiedAcpxRuntime(
.filter((server) => server.runnerOwned)
.map((server) => server.name),
);
const runtime = createRuntime({
const goalState: AcpxRuntimeGoalState = {
capability: null,
snapshot: null,
revision: 0,
observedSnapshot: false,
};
const acceptGoalNotification = (message: unknown): void => {
const update = goalSnapshotFromAcpMessage(message);
if (!update.seen) return;
const unchanged =
goalState.observedSnapshot &&
JSON.stringify(goalState.snapshot) === JSON.stringify(update.goal);
goalState.observedSnapshot = true;
goalState.snapshot = update.goal;
if (unchanged) return;
goalState.revision += 1;
options.onGoalUpdate?.(
update.goal === null ? null : structuredClone(update.goal),
);
};
const runtimeOptions: GoalAwareAcpRuntimeOptions = {
cwd: options.cwd,
sessionStore,
agentRegistry: createRegistry({
@ -262,6 +305,11 @@ export async function openQualifiedAcpxRuntime(
);
return disposition === "delegate" ? undefined : { outcome: disposition };
},
onAgentInitialize: (result) => {
const capability = goalCapabilityFromAcpMessage({ result });
if (capability) goalState.capability = capability;
},
onSessionNotification: acceptGoalNotification,
spawnEnvironment: () => ({
...definedEnvironment(options.launchEnvironment),
...(options.profile.agent === "claude"
@ -282,7 +330,8 @@ export async function openQualifiedAcpxRuntime(
}) as ChildProcess,
);
},
});
};
const runtime = createRuntime(runtimeOptions) as GoalAwareAcpRuntime;
admissionCleanup = new RuntimeAdmissionCleanup(
runtime,
children,
@ -379,6 +428,7 @@ export async function openQualifiedAcpxRuntime(
baseStore,
children,
runtimeCloseTimeoutMs,
goalState,
);
} catch (error) {
const cleanupReason = "ACPX runtime identity validation failed";
@ -798,12 +848,13 @@ function lateHandshakeCleanup(
}
function runtimePort(
runtime: AcpRuntime,
runtime: GoalAwareAcpRuntime,
handle: AcpRuntimeHandle,
identity: AcpxRuntimePortIdentity,
sessionStore: AcpSessionStore,
children: SpawnedChildSet,
runtimeCloseTimeoutMs: number,
goalState: AcpxRuntimeGoalState,
): AcpxRuntimePort {
type RuntimeCloseAttempt = {
readonly outcome: Promise<unknown | null>;
@ -1034,6 +1085,72 @@ function runtimePort(
async getStatus() {
return await persistedRuntimeStatus(sessionStore, handle, identity);
},
goalCapability() {
return goalState.capability === null
? null
: structuredClone(goalState.capability);
},
goalSnapshot() {
return goalState.snapshot === null
? null
: structuredClone(goalState.snapshot);
},
async controlGoal(action, objective) {
const capability = goalState.capability;
if (!capability || !capability.actions.includes(action)) {
throw new Error(`ACPX session goal action ${action} is unavailable`);
}
if (!runtime.requestExtension) {
throw new Error("ACPX runtime does not expose extension requests");
}
const revisionBeforeControl = goalState.revision;
await runtime.requestExtension({
handle,
method: capability.controlMethod,
params: {
sessionId: identity.agentSessionId,
action,
...(action === "set" ? { objective } : {}),
},
sessionMode: "persistent",
});
const shouldRepairMissingSetSnapshot =
action === "set" &&
Boolean(objective?.trim()) &&
goalState.snapshot === null;
const shouldRepairStaleClearSnapshot =
action === "clear" && goalState.snapshot !== null;
if (
goalState.revision === revisionBeforeControl ||
shouldRepairMissingSetSnapshot ||
shouldRepairStaleClearSnapshot
) {
const now = Date.now();
if (action === "set" && objective?.trim()) {
goalState.snapshot = {
objective: objective.trim(),
status: "active",
createdAt: now,
updatedAt: now,
};
} else if (
(action === "pause" || action === "resume") &&
goalState.snapshot
) {
goalState.snapshot = {
...goalState.snapshot,
status: action === "pause" ? "paused" : "active",
updatedAt: now,
};
} else if (action === "clear") {
goalState.snapshot = null;
}
goalState.revision += 1;
}
return goalState.snapshot === null
? null
: structuredClone(goalState.snapshot);
},
...(runtime.setConfigOption
? {
async setModel(model: string) {
@ -1592,6 +1709,101 @@ function pushUnique(errors: unknown[], error: unknown): void {
if (!errors.includes(error)) errors.push(error);
}
function objectRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function goalCapabilityFromAcpMessage(
message: unknown,
): AcpxRuntimeGoalCapability | null {
const result = objectRecord(objectRecord(message).result);
const goal = objectRecord(objectRecord(result._meta).goal);
const actions = Array.isArray(goal.actions)
? goal.actions.filter(
(action): action is "set" | "pause" | "resume" | "clear" =>
action === "set" ||
action === "pause" ||
action === "resume" ||
action === "clear",
)
: [];
if (
goal.version !== 1 ||
goal.controlMethod !== "_session/goal" ||
!actions.includes("set") ||
!actions.includes("clear")
) {
return null;
}
return { version: 1, controlMethod: goal.controlMethod, actions };
}
function goalSnapshotFromAcpMessage(message: unknown): {
seen: boolean;
goal: AcpxRuntimeGoalSnapshot | null;
} {
const envelope = objectRecord(message);
const update =
envelope.method === "session/update"
? objectRecord(objectRecord(envelope.params).update ?? envelope.params)
: Object.prototype.hasOwnProperty.call(envelope, "update")
? objectRecord(envelope.update)
: envelope.sessionUpdate === "session_info_update"
? envelope
: null;
if (update === null) return { seen: false, goal: null };
const meta = objectRecord(update._meta);
if (!Object.prototype.hasOwnProperty.call(meta, "goal")) {
return { seen: false, goal: null };
}
if (meta.goal === null) return { seen: true, goal: null };
const goal = objectRecord(meta.goal);
const objective =
typeof goal.objective === "string" ? goal.objective.trim() : "";
const status = goal.status;
if (
objective.length === 0 ||
objective.length > 4_000 ||
(status !== "active" &&
status !== "paused" &&
status !== "blocked" &&
status !== "limited" &&
status !== "complete")
) {
return { seen: false, goal: null };
}
const optionalNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
const optionalTimestamp = (
value: unknown,
): number | string | null | undefined =>
value === null || typeof value === "string" || typeof value === "number"
? value
: undefined;
return {
seen: true,
goal: {
objective,
status,
tokenBudget:
goal.tokenBudget === null ? null : optionalNumber(goal.tokenBudget),
tokensUsed: optionalNumber(goal.tokensUsed),
timeUsedSeconds: optionalNumber(goal.timeUsedSeconds),
iterations: optionalNumber(goal.iterations),
lastReason:
goal.lastReason === null || typeof goal.lastReason === "string"
? goal.lastReason
: undefined,
createdAt: optionalTimestamp(goal.createdAt),
updatedAt: optionalTimestamp(goal.updatedAt),
},
};
}
function requireIdentity(handle: AcpRuntimeHandle): AcpxRuntimePortIdentity {
const acpxRecordId = nonEmptyRuntimeIdentity(handle.acpxRecordId);
if (!acpxRecordId) throw new Error("ACPX runtime omitted acpxRecordId");

View File

@ -12,6 +12,9 @@ export const GENERATED_ACPX_SIDECAR_COMMANDS = [
"tool.resolve",
"session.read",
"session.snapshot",
"session.goal.get",
"session.goal.set",
"session.goal.clear",
"session.suspend",
"session.close",
] as const;
@ -26,6 +29,7 @@ export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
"runtime.turn_terminal",
"runtime.process",
"runtime.diagnostic",
"runtime.goal",
] as const;
export type GeneratedAcpxSidecarEventType =
(typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];

View File

@ -82,7 +82,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
agentRuntimePackage: "@openai/codex",
agentRuntimeVersion: "0.153.4",
commandDigest:
"sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400",
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
qualificationModel: "gpt-5.6-sol",
reportedModelId: "gpt-5.6-sol",
permissionPolicy: "interactive",

View File

@ -66,6 +66,24 @@ export interface AcpxRuntimePortIdentity {
agentSessionId: string;
}
export interface AcpxRuntimeGoalCapability {
version: number;
controlMethod: string;
actions: Array<"set" | "pause" | "resume" | "clear">;
}
export interface AcpxRuntimeGoalSnapshot {
objective: string;
status: "active" | "paused" | "blocked" | "limited" | "complete";
tokenBudget?: number | null;
tokensUsed?: number;
timeUsedSeconds?: number;
iterations?: number;
lastReason?: string | null;
createdAt?: number | string | null;
updatedAt?: number | string | null;
}
export interface AcpxRuntimeTurnInput {
text: string;
requestId: string;
@ -87,6 +105,12 @@ export interface AcpxRuntimePort {
identity(): Promise<AcpxRuntimePortIdentity>;
getStatus(): Promise<AcpxModelStatus>;
setModel?(model: string): Promise<void>;
goalCapability?(): AcpxRuntimeGoalCapability | null;
goalSnapshot?(): AcpxRuntimeGoalSnapshot | null;
controlGoal?(
action: "set" | "pause" | "resume" | "clear",
objective?: string,
): Promise<AcpxRuntimeGoalSnapshot | null>;
startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn;
close(input: { reason: string }): Promise<void>;
}
@ -110,6 +134,7 @@ export interface AcpxRuntimePortOpenOptions {
/** Abort provider admission and clean any runtime that resolves too late. */
signal?: AbortSignal;
mcpServers: readonly AcpxMcpServerBinding[];
onGoalUpdate?: (goal: AcpxRuntimeGoalSnapshot | null) => void;
/**
* Transfer the provider cleanup proof before a failed open settles. The host
* keeps credentials fenced until this exact cleanup succeeds.
@ -175,6 +200,7 @@ export interface OpenAcpxRuntimeHostOptions {
/** Abort admission without admitting resources that resolve afterward. */
signal?: AbortSignal;
semanticTools?: AcpxSemanticToolSession;
onGoalUpdate?: (goal: AcpxRuntimeGoalSnapshot | null) => void;
}
const RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS = 10;
@ -422,6 +448,9 @@ export class AcpxRuntimeHost {
},
]
: [],
...(options.onGoalUpdate === undefined
? {}
: { onGoalUpdate: options.onGoalUpdate }),
retainFailedAdmissionCleanup,
});
},
@ -551,6 +580,24 @@ export class AcpxRuntimeHost {
return structuredClone(await this.#runtime.getStatus());
}
goalCapability(): AcpxRuntimeGoalCapability | null {
return this.#runtime.goalCapability?.() ?? null;
}
goalSnapshot(): AcpxRuntimeGoalSnapshot | null {
return this.#runtime.goalSnapshot?.() ?? null;
}
async controlGoal(
action: "set" | "pause" | "resume" | "clear",
objective?: string,
): Promise<AcpxRuntimeGoalSnapshot | null> {
if (!this.#runtime.controlGoal) {
throw new Error("ACPX runtime does not expose session goal controls");
}
return await this.#runtime.controlGoal(action, objective);
}
startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn {
if (this.#closed || this.#closingStarted) {
throw new Error("ACPX runtime host is closing");

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { acpxGoalProjection } from "./session-goals.js";
import type { AcpxRuntimeGoalCapability } from "./runtime-host.js";
describe("negotiated ACP goal projection", () => {
const capability: AcpxRuntimeGoalCapability = {
version: 1, controlMethod: "_session/goal", actions: ["set", "clear"],
};
it("preserves action subsets without adding pause or budget controls", () => {
expect(acpxGoalProjection(capability, null, false).sessionGoals).toMatchObject({
availability: "available", actions: ["set", "clear"], tokenBudgetControl: false,
});
});
it.each([
null,
{ ...capability, version: 2 },
{ ...capability, controlMethod: "_arbitrary" },
{ ...capability, actions: ["set"] as const },
])("rejects missing or incompatible extensions (%j)", (value) => {
expect(acpxGoalProjection(value as AcpxRuntimeGoalCapability | null, null, false).sessionGoals)
.toMatchObject({ availability: "unsupported", actions: [] });
});
it("normalizes nullable usage, ISO timestamps, and out-of-prompt limited status", () => {
const projection = acpxGoalProjection(capability, {
objective: "Complete the task", status: "limited", createdAt: 0,
updatedAt: "2026-09-08T00:00:00Z",
}, false);
expect(projection.goal).toEqual({
objective: "Complete the task", status: "limited", tokenBudget: null,
tokensUsed: null, elapsedSeconds: null, iterations: null, lastReason: null,
createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "2026-09-08T00:00:00.000Z",
completedAt: null, workingNow: false,
});
});
});

View File

@ -0,0 +1,49 @@
import type { AcpxRuntimeGoalCapability, AcpxRuntimeGoalSnapshot } from "./runtime-host.js";
/** Only the negotiated extension, never the harness name, grants controls. */
export function acpxGoalProjection(
capability: AcpxRuntimeGoalCapability | null,
goal: AcpxRuntimeGoalSnapshot | null,
workingNow: boolean,
) {
const available = capability?.version === 1
&& capability.controlMethod === "_session/goal"
&& capability.actions.includes("set")
&& capability.actions.includes("clear");
const timestamp = (value: number | string | null | undefined): string | null => {
if (value == null) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
};
return {
schema: "paperclip.session_goal.snapshot.v1",
sessionGoals: {
availability: available ? "available" : "unsupported",
actions: available ? [...new Set(capability.actions)] : [],
autonomousUpdates: available,
persistentAcrossResume: available,
maxObjectiveChars: 4_000,
// v1 of the ACP goal extension does not negotiate budget control.
tokenBudgetControl: false,
usageReporting: available,
...(!available ? {
reasonCode: "persistent_session_goal_extension_required",
reason: "This persistent ACP session does not advertise a compatible goal extension with set and clear controls.",
} : {}),
},
goal: available && goal ? {
objective: goal.objective,
status: goal.status,
tokenBudget: goal.tokenBudget ?? null,
tokensUsed: goal.tokensUsed ?? null,
elapsedSeconds: goal.timeUsedSeconds ?? null,
iterations: goal.iterations ?? null,
lastReason: goal.lastReason ?? null,
createdAt: timestamp(goal.createdAt),
updatedAt: timestamp(goal.updatedAt),
completedAt: goal.status === "complete" ? timestamp(goal.updatedAt) : null,
workingNow,
} : null,
workingNow,
};
}

View File

@ -22,6 +22,7 @@ import {
} from "../../contracts/codex.js";
import { providerFamilyCapabilities } from "../../provider-events.js";
import {
CodexRpcError,
ProcessCodexAppServerTransport,
createSanitizedCodexEnvironment,
isCodexMethodUnavailable,
@ -50,6 +51,7 @@ import { CodexHarnessSession } from "./codex-harness-session.js";
import type {
CodexAppServerDriverOptions,
CodexCapabilities,
CodexGoalAvailability,
OpenedCodexThread,
} from "./codex-driver-types.js";
import {
@ -122,6 +124,12 @@ function bootstrapCancellation(
export class CodexAppServerDriver implements HarnessDriver {
readonly #options: CodexAppServerDriverOptions;
readonly #caps: CodexCapabilities;
#goalAvailability: CodexGoalAvailability;
#goalReasonCode: string | null = null;
#goalReason: string | null = null;
readonly #goalCapability: NonNullable<
CodexAppServerDriverOptions["goalCapability"]
>;
readonly #persistedProcessIdentities = new WeakMap<object, string>();
constructor(options: CodexAppServerDriverOptions) {
@ -139,6 +147,19 @@ export class CodexAppServerDriver implements HarnessDriver {
threadLineage: true,
...options.capabilities,
};
this.#goalAvailability = this.#caps.goals ? "available" : "unsupported";
this.#goalCapability = options.goalCapability ?? {
actions: ["set", "pause", "resume", "clear"],
autonomousUpdates: true,
persistentAcrossResume: true,
maxObjectiveChars: 4_000,
tokenBudgetControl: true,
usageReporting: true,
};
if (!this.#caps.goals) {
this.#goalReasonCode = "codex_goal_api_unavailable";
this.#goalReason = "This Codex app-server does not expose thread goals.";
}
if (!this.#caps.read) this.#caps.reconciliation = false;
}
@ -274,6 +295,9 @@ export class CodexAppServerDriver implements HarnessDriver {
normalizedSessionId: input.normalizedSessionId,
opened,
goal,
goalAvailability: this.#goalAvailability,
goalReasonCode: this.#goalReasonCode,
goalReason: this.#goalReason,
resumed: false,
sourceSequence: 0,
});
@ -425,7 +449,33 @@ export class CodexAppServerDriver implements HarnessDriver {
snapshot.dispositionOnlyRecoveryTurnId ?? null;
let reconcileUncheckpointedDispositionTurn = false;
let providerTurnIds: Set<string> | null = null;
const goal = await cancellation.wait(
this.#discoverGoal(transport, opened.threadId),
);
const recoveringAutonomousGoal = snapshot.goal?.status === "active"
&& goal != null
&& goal.createdAt === snapshot.goal.createdAt;
if (recoveringAutonomousGoal) {
// Goal activation and continuation have no turn/start response. The
// provider can advance beyond the last controller checkpoint while
// disconnected, so bind the single live turn from the authenticated,
// identity-checked thread read before draining its notifications.
// Never infer a turn from an arbitrary notification or another goal.
const turns = Array.isArray(existingThread.turns)
? existingThread.turns.map(record)
: null;
const active = turns?.filter((turn) => text(turn.status) === "inProgress");
if (!active || active.length > 1 || (active.length === 1 && (
!text(active[0]?.id)
|| (snapshot.terminalTurns ?? []).some((turn) => turn.turnId === text(active[0]?.id))
))) {
await cancellation.wait(cancellation.close());
return { recovered: false, reason: "provider exposed ambiguous autonomous goal turn history" };
}
recoveredActiveTurnId = active.length === 1 ? text(active[0]?.id) : recoveredActiveTurnId;
}
if (
!recoveringAutonomousGoal &&
!this.#direct() &&
snapshot.semanticResult == null &&
recoveredActiveTurnId === null &&
@ -516,9 +566,6 @@ export class CodexAppServerDriver implements HarnessDriver {
dispositionOnlyRecoveryConsumed = false;
dispositionOnlyRecoveryTurnId = null;
}
const goal = await cancellation.wait(
this.#discoverGoal(transport, opened.threadId),
);
if (opened.context.liveConsole)
opened.context.liveConsole.goals = this.#caps.goals;
const session = this.#session({
@ -527,6 +574,9 @@ export class CodexAppServerDriver implements HarnessDriver {
normalizedSessionId: snapshot.normalizedSessionId,
opened,
goal,
goalAvailability: this.#goalAvailability,
goalReasonCode: this.#goalReasonCode,
goalReason: this.#goalReason,
resumed: true,
activeTurnId: recoveredActiveTurnId,
semanticResult: snapshot.semanticResult ?? null,
@ -652,10 +702,25 @@ export class CodexAppServerDriver implements HarnessDriver {
const response = await transport.request("thread/goal/get", { threadId });
return parseThreadGoal(response.goal);
} catch (error) {
if (isCodexMethodUnavailable(error)) {
const policyDisabled =
error instanceof CodexRpcError
&& (error.message.toLowerCase().includes("policy")
|| error.message.toLowerCase().includes("disabled"));
if (policyDisabled || isCodexMethodUnavailable(error)) {
// The provider answered, and its answer is that this build has no goal
// API. That is the only evidence that retires the capability.
this.#caps.goals = false;
if (policyDisabled) {
this.#goalAvailability = "policy_disabled";
this.#goalReasonCode = "codex_goal_policy_disabled";
this.#goalReason =
"Session goals are disabled by the Codex provider policy.";
} else {
this.#goalAvailability = "unsupported";
this.#goalReasonCode = "codex_goal_api_unavailable";
this.#goalReason =
"This Codex app-server does not expose thread goals.";
}
this.#options.onDiagnostic?.(
redactCodexDiagnostic(`thread goals unavailable: ${String(error)}`),
);
@ -794,6 +859,9 @@ export class CodexAppServerDriver implements HarnessDriver {
normalizedSessionId: string;
opened: OpenedCodexThread;
goal?: HarnessThreadGoal | null;
goalAvailability: CodexGoalAvailability;
goalReasonCode: string | null;
goalReason: string | null;
resumed: boolean;
activeTurnId?: string | null;
semanticResult?: PersistedHarnessSemanticResult | null;
@ -812,6 +880,7 @@ export class CodexAppServerDriver implements HarnessDriver {
runnerInstanceId: this.#options.runnerInstanceId ?? "runner-codex",
driverKind: this.#options.driverIdentity?.kind ?? DRIVER_KIND,
capabilities: this.#caps,
goalCapability: this.#goalCapability,
dynamicTools: this.#options.dynamicTools ?? [],
dynamicToolHandler: this.#options.dynamicToolHandler,
});

View File

@ -63,7 +63,8 @@ describe("Codex app-server Codex driver", () => {
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
const iterator = session.events()[Symbol.asyncIterator]();
const events: PrpEvent[] = [];
for (let index = 0; index < 6; index += 1) {
// PRP v2 emits capability and goal snapshots before turn events.
for (let index = 0; index < 8; index += 1) {
const next = await iterator.next();
if (next.value) events.push(next.value);
}

View File

@ -475,7 +475,9 @@ describe("Codex app-server Codex driver", () => {
).rejects.toBeInstanceOf(HarnessStaleTurnError);
const events = session.events()[Symbol.asyncIterator]();
const observed: PrpEvent[] = [];
for (let index = 0; index < 9; index += 1) {
// PRP v2 adds a capability and authoritative goal snapshot immediately
// after session start, so include those two events in this bounded read.
for (let index = 0; index < 11; index += 1) {
const next = await events.next();
if (next.done) break;
observed.push(next.value);
@ -601,6 +603,14 @@ describe("Codex app-server Codex driver", () => {
await expect(
unsupported.goal?.({ action: "get" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
for (const action of ["set", "pause", "resume", "clear"] as const) {
await expect(
unsupported.goal?.({ action, ...(action === "set" ? { objective: "Must not reach the provider" } : {}) }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
}
expect(unsupportedTransport.calls.filter(({ method }) =>
method === "thread/goal/set" || method === "thread/goal/clear",
)).toEqual([]);
await unsupported.close({ reason: "fixture complete" });
}
await session.close({ reason: "fixture complete" });

View File

@ -44,6 +44,57 @@ import {
} from "./codex-app-server-driver.test-support.js";
describe("Codex app-server Codex driver", () => {
it.each([null, "checkpointed-prior-turn"])("recovers an autonomous goal turn beyond checkpoint %s", async (checkpointTurnId) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second]);
const original = await driver.openSession({
runId: "run-goal-recovery", normalizedSessionId: "normalized-goal-recovery", workingDirectory: WORKSPACE,
});
await original.goal?.({ action: "set", objective: "Continue across a controller crash" });
const snapshot = await original.snapshot();
snapshot.activeTurnId = checkpointTurnId;
second.goalState = structuredClone(first.goalState);
second.readResponse = { thread: {
id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
turns: [{ id: "autonomous-live-turn", status: "inProgress", items: [] }],
} };
await original.close({ reason: "controller lost before goal turn checkpoint" });
const recovery = await driver.recoverSession?.(snapshot);
expect(recovery).toMatchObject({ recovered: true });
const recovered = recovery!.session!;
expect(await recovered.snapshot()).toMatchObject({ activeTurnId: "autonomous-live-turn" });
second.push("item/started", {
threadId: "thread-1", turnId: "autonomous-live-turn", item: { id: "recovered-item", type: "agentMessage", text: "Continuing" },
});
const iterator = recovered.events()[Symbol.asyncIterator]();
let event: PrpEvent | undefined;
do { event = (await iterator.next()).value; } while (event && event.eventType !== "item.started" && !event.eventType.startsWith("run."));
expect(event).toMatchObject({ eventType: "item.started", turnId: "autonomous-live-turn" });
expect(second.calls.some((call) => call.method === "turn/start" || call.method === "thread/goal/set")).toBe(false);
await recovered.close({ reason: "test complete" });
});
it.each([undefined, [{ id: "", status: "inProgress" }], [
{ id: "first", status: "inProgress" }, { id: "second", status: "inProgress" },
]])("rejects ambiguous autonomous goal history %j", async (turns) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second]);
const original = await driver.openSession({
runId: "run-goal-recovery", normalizedSessionId: "normalized-goal-recovery", workingDirectory: WORKSPACE,
});
await original.goal?.({ action: "set", objective: "Continue across a controller crash" });
const snapshot = await original.snapshot();
second.goalState = structuredClone(first.goalState);
second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, turns } };
await original.close({ reason: "controller lost" });
await expect(driver.recoverSession?.(snapshot)).resolves.toEqual({
recovered: false, reason: "provider exposed ambiguous autonomous goal turn history",
});
expect(second.calls.some((call) => call.method === "turn/start" || call.method === "thread/goal/set")).toBe(false);
});
it("persists and verifies the tagged runnerd provider identity on recovery", async () => {
const providerIdentity = {
kind: "acpx",

View File

@ -37,7 +37,6 @@ import {
validateCodexResultProposal,
} from "../../mock-core/codex-runner.js";
import {
CODEX_INVALID_REQUEST,
CODEX_METHOD_NOT_FOUND,
CodexRpcError,
type CodexAppServerTransport,
@ -1658,7 +1657,9 @@ describe("Codex app-server Codex driver", () => {
).rejects.toBeInstanceOf(HarnessStaleTurnError);
const events = session.events()[Symbol.asyncIterator]();
const observed: PrpEvent[] = [];
for (let index = 0; index < 9; index += 1) {
// PRP v2 adds a capability and authoritative goal snapshot immediately
// after session start, so include those two events in this bounded read.
for (let index = 0; index < 11; index += 1) {
const next = await events.next();
if (next.done) break;
observed.push(next.value);
@ -1760,15 +1761,21 @@ describe("Codex app-server Codex driver", () => {
// Both denials a real app-server sends: the method is absent, and the
// build has the feature switched off.
for (const denial of [
new CodexRpcError(
'{"code":-32601,"message":"method not found"}',
CODEX_METHOD_NOT_FOUND,
),
new CodexRpcError(
'{"code":-32600,"message":"goals feature is disabled"}',
CODEX_INVALID_REQUEST,
),
for (const { denial, availability } of [
{
denial: new CodexRpcError(
'{"code":-32601,"message":"method not found"}',
CODEX_METHOD_NOT_FOUND,
),
availability: "unsupported",
},
{
denial: new CodexRpcError(
'{"code":-32004,"message":"goals feature is disabled by policy"}',
-32_004,
),
availability: "policy_disabled",
},
]) {
const unsupportedTransport = new FakeCodexTransport();
unsupportedTransport.rejectMethods.set("thread/goal/get", denial);
@ -1781,6 +1788,14 @@ describe("Codex app-server Codex driver", () => {
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject(
{ goals: false },
);
const unsupportedEvents = unsupported.events()[Symbol.asyncIterator]();
await unsupportedEvents.next();
await expect(unsupportedEvents.next()).resolves.toMatchObject({
value: {
eventType: "session.capabilities.updated",
payload: { sessionGoals: { availability } },
},
});
await expect(
unsupported.goal?.({ action: "get" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
@ -1819,6 +1834,128 @@ describe("Codex app-server Codex driver", () => {
}
});
it("preserves an inactive goal status while editing its objective", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-goal-inactive-edit",
normalizedSessionId: "normalized-goal-inactive-edit",
workingDirectory: TEST_WORKING_DIRECTORY,
});
await session.goal?.({
action: "set",
objective: "Updated while paused",
status: "paused",
});
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toContainEqual({
method: "thread/goal/set",
params: {
threadId: "thread-1",
objective: "Updated while paused",
status: "paused",
},
});
await session.close({ reason: "fixture complete" });
});
it("rolls back only a definitely rejected idle goal autostart", async () => {
const definiteTransport = new FakeCodexTransport();
const definiteSession = await makeDriver([definiteTransport]).openSession({
runId: "run-goal-autostart-definite-rejection",
normalizedSessionId: "normalized-goal-autostart-definite-rejection",
workingDirectory: TEST_WORKING_DIRECTORY,
});
definiteTransport.rejectMethods.set(
"thread/goal/set",
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
);
await expect(
definiteSession.goal?.({
action: "set",
objective: "Rejected before a turn starts",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
definiteTransport.rejectMethods.delete("thread/goal/set");
await expect(
definiteSession.startTurn({
message: { role: "user", text: "A normal turn may still start." },
}),
).resolves.toMatchObject({ turnId: "turn-1" });
await definiteSession.close({ reason: "fixture complete" });
const ambiguousTransport = new FakeCodexTransport();
const ambiguousSession = await makeDriver([ambiguousTransport]).openSession({
runId: "run-goal-autostart-ambiguous",
normalizedSessionId: "normalized-goal-autostart-ambiguous",
workingDirectory: TEST_WORKING_DIRECTORY,
});
ambiguousTransport.rejectMethods.set(
"thread/goal/set",
new Error("codex app-server transport closed"),
);
await expect(
ambiguousSession.goal?.({
action: "set",
objective: "The provider may have started this goal",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
ambiguousTransport.rejectMethods.delete("thread/goal/set");
await expect(
ambiguousSession.startTurn({
message: { role: "user", text: "Do not create competing work." },
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
await ambiguousSession.close({ reason: "fixture complete" });
});
it("preserves a provider-neutral goal action subset through the Codex transport facade", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], {
goalCapability: {
actions: ["set", "clear"],
autonomousUpdates: true,
persistentAcrossResume: true,
maxObjectiveChars: 4_000,
tokenBudgetControl: false,
usageReporting: true,
},
});
const session = await driver.openSession({
runId: "run-goals-action-subset",
normalizedSessionId: "normalized-goals-action-subset",
workingDirectory: TEST_WORKING_DIRECTORY,
});
const events = session.events()[Symbol.asyncIterator]();
await events.next();
await expect(events.next()).resolves.toMatchObject({
value: {
eventType: "session.capabilities.updated",
payload: {
sessionGoals: {
availability: "available",
actions: ["set", "clear"],
tokenBudgetControl: false,
},
},
},
});
await expect(
session.goal?.({ action: "pause" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toHaveLength(0);
await expect(
session.goal?.({ action: "set", objective: "Finish the task" }),
).resolves.toMatchObject({ objective: "Finish the task" });
await expect(session.goal?.({ action: "clear" })).resolves.toBeNull();
await session.close({ reason: "fixture complete" });
});
it("validates runtime request resolutions against the kind of request they answer", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
@ -3501,7 +3638,8 @@ describe("Codex app-server Codex driver", () => {
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
const iterator = session.events()[Symbol.asyncIterator]();
const events: PrpEvent[] = [];
for (let index = 0; index < 6; index += 1) {
// PRP v2 emits capability and goal snapshots before turn events.
for (let index = 0; index < 8; index += 1) {
const next = await iterator.next();
if (next.value) events.push(next.value);
}

View File

@ -71,6 +71,15 @@ export interface CodexAppServerDriverOptions {
goals: boolean;
threadLineage: boolean;
}>;
/** Provider-neutral goal metadata for transports exposing a compatible goal lifecycle. */
goalCapability?: {
actions: readonly ("set" | "pause" | "resume" | "clear")[];
autonomousUpdates: boolean;
persistentAcrossResume: boolean;
maxObjectiveChars: number;
tokenBudgetControl: boolean;
usageReporting: boolean;
};
/** Provider-specific identity retained when the Codex protocol facade is backed by runnerd. */
driverIdentity?: {
kind: string;
@ -85,6 +94,11 @@ export type CodexCapabilities = Required<
NonNullable<CodexAppServerDriverOptions["capabilities"]>
>;
export type CodexGoalAvailability =
| "available"
| "unsupported"
| "policy_disabled";
export type SemanticResultAdmission = "committed" | "identical" | "conflict";
export interface TerminalReplayConflict {

View File

@ -490,6 +490,27 @@ export class CodexHarnessSession
async goal(input: HarnessGoalOperation): Promise<HarnessThreadGoal | null> {
this.requireCapability("goals");
if (
input.action !== "get"
&& !this.goalCapability.actions.includes(input.action)
) {
throw this.unsupported(
`goal ${input.action}`,
"capability action not advertised",
);
}
const expectsIdleAutostart =
this.activeTurnId === null
&& !this.turnStartPending
&& (input.action === "resume"
|| (input.action === "set" && (input.status ?? "active") === "active"));
if (expectsIdleAutostart) {
// Codex activates an idle goal by starting a provider turn without a
// turn/start response. Keep the expectation armed until turn/started
// supplies the authoritative turn id; the notification may arrive
// after thread/goal/set has already returned.
this.turnStartPending = true;
}
let method: string;
let params: Record<string, unknown> = { threadId: this.opened.threadId };
if (input.action === "get") {
@ -502,7 +523,7 @@ export class CodexHarnessSession
params = {
...params,
objective: input.objective,
status: "active",
status: input.status ?? "active",
...(input.tokenBudget !== undefined
? { tokenBudget: input.tokenBudget }
: {}),
@ -538,8 +559,26 @@ export class CodexHarnessSession
},
{ itemId: `${this.opened.threadId}:goal:${this.sourceSequence + 1}` },
);
this.emitGoalEvent(
input.action === "clear"
? "session.goal.cleared"
: input.action === "get"
? "session.goal.snapshot"
: "session.goal.updated",
goal,
{
...(input.requestId ? { requestId: input.requestId } : {}),
workingNow: this.activeTurnId !== null,
},
);
return goal === null ? null : structuredClone(goal);
} catch (error) {
if (expectsIdleAutostart && error instanceof CodexRpcError) {
// A JSON-RPC error is a definite provider rejection. Transport and
// protocol failures are ambiguous and deliberately retain the pending
// start so another command cannot create competing provider work.
this.turnStartPending = false;
}
throw this.unsupported(`goal ${input.action}`, error);
}
}

View File

@ -234,6 +234,9 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
itemId: `${threadId}:goal:update:${state.sourceSequence + 1}`,
},
);
state.emitGoalEvent("session.goal.updated", goal, {
workingNow: state.activeTurnId !== null,
});
return;
}
if (notification.method === "thread/goal/cleared") {
@ -249,6 +252,9 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
},
{ itemId: `${threadId}:goal:clear:${state.sourceSequence + 1}` },
);
state.emitGoalEvent("session.goal.cleared", null, {
workingNow: state.activeTurnId !== null,
});
return;
}
if (notification.method === "serverRequest/resolved") {
@ -341,6 +347,20 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
return;
}
if (notification.method === "turn/started") {
const autonomousGoalTurn = state.currentGoal?.status === "active"
&& state.activeTurnId === null
&& !state.turnStartPending
&& !state.protocolFailed
&& !state.terminalTurns.has(turnId);
if (autonomousGoalTurn) {
state.terminal = false;
state.turnStarted = false;
state.turnStartPending = true;
state.result = null;
state.resultFingerprint = null;
state.resultCallId = null;
state.resultTurnId = null;
}
if (
turnId.length === 0 ||
state.terminal ||
@ -356,6 +376,7 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
return;
}
state.activeTurnId = turnId;
state.turnStartPending = false;
state.turnStarted = true;
state.emit(
"turn.started",

View File

@ -24,6 +24,7 @@ import { safeCodexRequestResponse as safeRequestResponse } from "./codex-thread-
import type {
CodexAppServerDriverOptions,
CodexCapabilities,
CodexGoalAvailability,
OpenedCodexThread,
PendingRuntimeRequest,
} from "./codex-driver-types.js";
@ -75,6 +76,12 @@ export class CodexSessionState {
readonly runnerInstanceId: string;
readonly driverKind: string;
readonly capabilities: CodexCapabilities;
readonly goalCapability: NonNullable<
CodexAppServerDriverOptions["goalCapability"]
>;
readonly goalAvailability: CodexGoalAvailability;
readonly goalReasonCode: string | null;
readonly goalReason: string | null;
readonly dynamicTools: readonly Readonly<Record<string, unknown>>[];
readonly dynamicToolHandler: CodexAppServerDriverOptions["dynamicToolHandler"];
readonly eventQueue = new AsyncQueue<PrpEvent>();
@ -138,6 +145,10 @@ export class CodexSessionState {
runnerInstanceId: string;
driverKind: string;
capabilities: CodexCapabilities;
goalCapability: NonNullable<CodexAppServerDriverOptions["goalCapability"]>;
goalAvailability: CodexGoalAvailability;
goalReasonCode: string | null;
goalReason: string | null;
dynamicTools: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler?: CodexAppServerDriverOptions["dynamicToolHandler"];
}) {
@ -154,6 +165,10 @@ export class CodexSessionState {
this.runnerInstanceId = input.runnerInstanceId;
this.driverKind = input.driverKind;
this.capabilities = input.capabilities;
this.goalCapability = input.goalCapability;
this.goalAvailability = input.goalAvailability;
this.goalReasonCode = input.goalReasonCode;
this.goalReason = input.goalReason;
this.dynamicTools = input.dynamicTools;
this.dynamicToolHandler = input.dynamicToolHandler;
this.currentGoal = input.goal === undefined ? null : structuredClone(input.goal);
@ -370,6 +385,111 @@ export class CodexSessionState {
payload,
});
}
emitGoalCapabilities(): void {
this.emitV2("session.capabilities.updated", {
sessionGoals:
this.goalAvailability === "available" && this.capabilities.goals
? {
availability: "available",
actions: [...this.goalCapability.actions],
autonomousUpdates: this.goalCapability.autonomousUpdates,
persistentAcrossResume:
this.goalCapability.persistentAcrossResume,
maxObjectiveChars: this.goalCapability.maxObjectiveChars,
tokenBudgetControl: this.goalCapability.tokenBudgetControl,
usageReporting: this.goalCapability.usageReporting,
}
: {
availability: this.goalAvailability,
actions: [],
autonomousUpdates: false,
persistentAcrossResume: false,
maxObjectiveChars: 4_000,
tokenBudgetControl: false,
usageReporting: false,
reasonCode:
this.goalReasonCode ?? "codex_goal_api_unavailable",
reason:
this.goalReason
?? "This Codex app-server does not expose thread goals.",
},
});
}
emitGoalEvent(
eventType:
| "session.goal.snapshot"
| "session.goal.updated"
| "session.goal.cleared",
goal: HarnessThreadGoal | null,
extra: Record<string, unknown> = {},
): void {
this.emitV2(eventType, {
goal:
goal === null
? null
: normalizedGoalSnapshot(goal, this.activeTurnId !== null),
...extra,
});
}
private emitV2(
eventType:
| "session.capabilities.updated"
| "session.goal.snapshot"
| "session.goal.updated"
| "session.goal.cleared",
payload: Record<string, unknown>,
): void {
const sourceSeq = ++this.sourceSequence;
this.eventQueue.push({
schema: "paperclip.prp.event.v2",
sourceEventId: `${this.runnerInstanceId}:${this.runId}:${sourceSeq}`,
sourceSeq,
sourceInstanceId: this.runnerInstanceId,
sourceKind: "runner",
runId: this.runId,
normalizedSessionId: this.normalizedSessionId,
...(this.activeTurnId ? { turnId: this.activeTurnId } : {}),
eventType,
schemaVersion: 2,
priority: 0,
emittedAt: this.now().toISOString(),
payload,
} as PrpEvent);
}
}
function normalizedGoalSnapshot(
goal: HarnessThreadGoal,
workingNow: boolean,
): Record<string, unknown> {
const isoTimestamp = (value: number): string | null => {
if (!Number.isFinite(value) || value <= 0) return null;
const milliseconds = value < 10_000_000_000 ? value * 1_000 : value;
const date = new Date(milliseconds);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
};
const status =
goal.status === "usageLimited"
? "usage_limited"
: goal.status === "budgetLimited"
? "budget_limited"
: goal.status;
return {
objective: goal.objective,
status,
tokenBudget: goal.tokenBudget,
tokensUsed: goal.tokensUsed,
elapsedSeconds: goal.timeUsedSeconds,
iterations: 0,
lastReason: null,
createdAt: isoTimestamp(goal.createdAt),
updatedAt: isoTimestamp(goal.updatedAt),
completedAt: goal.status === "complete" ? isoTimestamp(goal.updatedAt) : null,
workingNow,
};
}
export type CodexSessionStateInput = ConstructorParameters<typeof CodexSessionState>[0];
@ -383,6 +503,10 @@ export function initializeCodexSessionEvents(
providerSessionId: input.opened.providerSessionId,
context: input.opened.context,
});
state.emitGoalCapabilities();
state.emitGoalEvent("session.goal.snapshot", state.currentGoal, {
workingNow: state.activeTurnId !== null,
});
for (const stale of input.stalePendingRuntimeRequests ?? []) {
state.emit(
"runtime_request.cancelled",

View File

@ -84,6 +84,7 @@ export function admitResult(
function finalize(
state: CodexSessionState,turnStatus: string): void {
if (state.conversationMode === "direct") return;
if (state.currentGoal?.status === "active") return;
if (state.terminal) return;
if (state.result === null) {
state.emit("harness.diagnostic", {

View File

@ -1,7 +1,11 @@
import { createHash } from "node:crypto";
import { PAPERCLIP_RUNNER_COMPATIBILITY } from "../compatibility.js";
import { PRP_PROTOCOL_NAME, PRP_PROTOCOL_VERSION } from "../protocol/replay-contract.js";
import {
PRP_PROTOCOL_MIN_VERSION,
PRP_PROTOCOL_NAME,
PRP_PROTOCOL_VERSION,
} from "../protocol/replay-contract.js";
import { canonicalCapabilitySemanticCatalog } from "../semantic-tools/catalog.js";
export const PAPERCLIP_RUNNER_BUILD_METADATA_SCHEMA =
@ -41,7 +45,7 @@ export const PAPERCLIP_RUNNER_BUILD_METADATA = Object.freeze({
}),
prp: Object.freeze({
name: PRP_PROTOCOL_NAME,
minimumVersion: PRP_PROTOCOL_VERSION,
minimumVersion: PRP_PROTOCOL_MIN_VERSION,
maximumVersion: PRP_PROTOCOL_VERSION,
}),
semanticCatalog: Object.freeze({

View File

@ -80,8 +80,8 @@ describe("Paperclip Evals integration compatibility", () => {
requirement.runnerd.nativeExecutionVersion = 2;
requirement.runnerd.harnessDriverVersion = 2;
requirement.nativeExecutionVersion = 2;
requirement.prp = { minimumVersion: 2, maximumVersion: 2 };
requirement.runnerd.prp = { name: "paperclip.runner", minimumVersion: 2, maximumVersion: 2 };
requirement.prp = { minimumVersion: 3, maximumVersion: 3 };
requirement.runnerd.prp = { name: "paperclip.runner", minimumVersion: 3, maximumVersion: 3 };
requirement.catalog = { version: 2, sha256: `sha256:${"0".repeat(64)}` };
requirement.driver.contractVersion = 2;
requirement.driver.descriptor.capabilities.dynamicTools = false;

View File

@ -43,6 +43,8 @@ import {
createRunnerdCodexAppServerArgs,
defaultCapabilityRunnerdBinary,
expandRunnerdCanonicalNotifications,
latestRunnerdSessionReadiness,
rehydrateRunnerdGoalNotification,
rehydrateRunnerdItemNotification,
rehydrateRunnerdPlanNotification,
rehydrateRunnerdResultNotification,
@ -50,12 +52,14 @@ import {
rehydrateRunnerdTurnNotification,
rehydrateRunnerdUsageNotification,
rehydrateRunnerdWorkspaceChangeNotification,
runnerdCanonicalNotificationMethod,
runnerdLaunchProfileInternals,
runnerdRecoveryInternals,
resolveRunnerdAcpxPermissionMode,
resolveRunnerdSessionIdentity,
resolveSourceCodexHome,
trustedRuntimeReadOnlyRoots,
unseenRunnerdCommittedEvents,
unwrapRunnerdProviderNotification,
unwrapRunnerdProviderNotifications,
withCodexCollaborationRuntimeInstructions,
@ -1350,6 +1354,73 @@ it("rehydrates canonical workspace changes without reconstructing the diff", ()
});
});
it("rehydrates canonical session goals into Codex goal notifications", () => {
expect(
rehydrateRunnerdGoalNotification(
{
goal: {
objective: "Finish the browser lifecycle",
status: "complete",
tokenBudget: 20_000,
tokensUsed: 12_345,
elapsedSeconds: 42,
},
workingNow: false,
},
"thread-1",
"thread/goal/updated",
),
).toEqual({
threadId: "thread-1",
goal: {
threadId: "thread-1",
objective: "Finish the browser lifecycle",
status: "complete",
tokenBudget: 20_000,
tokensUsed: 12_345,
timeUsedSeconds: 42,
createdAt: 0,
updatedAt: 0,
},
workingNow: false,
});
expect(
rehydrateRunnerdGoalNotification(
{ revision: 7, workingNow: false },
"thread-1",
"thread/goal/cleared",
),
).toEqual({ revision: 7, threadId: "thread-1", workingNow: false });
});
it("routes canonical session goals back through the Codex notification facade", () => {
expect(
runnerdCanonicalNotificationMethod("session.goal.updated", {
goal: { status: "complete" },
}),
).toBe("thread/goal/updated");
expect(
runnerdCanonicalNotificationMethod("session.goal.snapshot", { goal: null }),
).toBeUndefined();
expect(runnerdCanonicalNotificationMethod("session.goal.cleared", {})).toBe(
"thread/goal/cleared",
);
});
it("continues consuming after the durable committed-event window rolls", () => {
const rollingWindow = Array.from({ length: 64 }, (_, index) => ({
sourceSeq: index + 65,
eventType: index === 62 ? "session.goal.updated" : "item.delta",
}));
expect(unseenRunnerdCommittedEvents(rollingWindow, 64)).toEqual(
rollingWindow,
);
expect(unseenRunnerdCommittedEvents(rollingWindow, 128)).toEqual([]);
expect(() => unseenRunnerdCommittedEvents(rollingWindow, 63)).toThrow(
"provider_notification_window_exceeded",
);
});
it("resolves canonical and legacy durable session identities", () => {
expect(
resolveRunnerdSessionIdentity({
@ -1387,6 +1458,36 @@ it("resolves canonical and legacy durable session identities", () => {
});
});
it("recovers provider readiness from an already-committed journal without replay", () => {
const persistedReady = {
provider: "codex",
providerSessionId: "provider-thread-persisted",
providerAccountSessionId: "provider-account-persisted",
processId: 4242,
runtimeIdentity: { executionKind: "local_process" },
providerDescriptor: {
driver: "codex_app_server",
providerVersion: "persisted-version",
},
providerIdentity: {
kind: "codex_thread",
threadId: "provider-thread-persisted",
},
};
expect(
latestRunnerdSessionReadiness([
{
eventType: "harness.ready",
envelope: { payload: { payload: persistedReady } },
},
{
eventType: "session.goal.snapshot",
envelope: { payload: { payload: { goal: { status: "paused" } } } },
},
]),
).toEqual(persistedReady);
});
const fakeCodex = resolve(
import.meta.dirname,
"../../runner/target/debug/fake-codex-app-server",
@ -1574,6 +1675,199 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
});
}, 30_000);
it("controls a Codex session goal end to end through durable PRP v2", async () => {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-goal-provider-"));
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodex,
codexArgs: fakeCodexArgs(stateDirectory, "--goal-autostart"),
stateDirectory,
});
bundle.transport.setServerRequestHandler(async () => ({
success: true,
contentItems: [],
}));
try {
await bundle.transport.request("initialize", {});
const opened = await bundle.transport.request("thread/start", {
cwd: tmpdir(),
dynamicTools: [
{
name: "get_task_context",
description: "Read the active task.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
],
});
const threadId = opened.thread.id;
await expect(
bundle.transport.request("thread/goal/set", {
threadId,
objective: "Finish the durable PRP goal test",
status: "active",
tokenBudget: 12_000,
}),
).resolves.toMatchObject({
goal: {
threadId,
objective: "Finish the durable PRP goal test",
status: "active",
tokenBudget: 12_000,
},
});
let durableGoalEvent: Record<string, unknown> | null = null;
let durableTurnStarted = false;
const deliveryDeadline = Date.now() + 5_000;
while (Date.now() < deliveryDeadline) {
const controlState = JSON.parse(
await readFile(
join(stateDirectory, "control-plane", "control-plane-state.json"),
"utf8",
),
) as {
committedEvents?: Array<Record<string, unknown>>;
};
durableGoalEvent =
controlState.committedEvents?.find(
(event) => event.eventType === "session.goal.updated",
) ?? null;
durableTurnStarted =
controlState.committedEvents?.some(
(event) => event.eventType === "turn.started",
) ?? false;
if (durableGoalEvent !== null && durableTurnStarted) break;
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
}
expect(durableGoalEvent).not.toBeNull();
expect(durableTurnStarted).toBe(true);
expect(durableGoalEvent).toMatchObject({
envelope: {
payload: {
payload: {
goal: { lastReason: null },
},
},
},
});
await expect(
bundle.transport.request("thread/goal/get", { threadId }),
).resolves.toMatchObject({
goal: {
threadId,
objective: "Finish the durable PRP goal test",
status: "active",
},
});
await expect(
bundle.transport.request("thread/goal/set", {
threadId,
status: "paused",
}),
).resolves.toMatchObject({ goal: { status: "paused" } });
await expect(
bundle.transport.request("thread/goal/set", {
threadId,
status: "active",
}),
).resolves.toMatchObject({ goal: { status: "active" } });
await expect(
bundle.transport.request("thread/goal/clear", { threadId }),
).resolves.toEqual({});
await expect(
bundle.transport.request("thread/goal/get", { threadId }),
).resolves.toEqual({ goal: null });
} finally {
await bundle.transport.close();
await rm(stateDirectory, { recursive: true, force: true });
}
expect(bundle.evidence()).toMatchObject({
runnerExited: true,
runnerExitCode: 0,
});
}, 30_000);
it.each([false, true])("binds goal turns through the full Codex harness (autonomous continuation: %s)", async (autocontinue) => {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-goal-harness-"));
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodex,
codexArgs: fakeCodexArgs(stateDirectory, "--goal-autostart", ...(autocontinue ? ["--goal-autocontinue"] : [])),
stateDirectory,
});
const driver = new CodexAppServerDriver({
taskEnvelope: {
schema: "paperclip.skillless_task.v1",
objective: "Finish the durable goal harness test.",
completionContract: {
revision: "goal-harness-v1",
criteria: [{ id: "goal", requirement: "The goal turn starts." }],
},
constraints: [],
expectedResultSchema: "paperclip.run_result.v1",
},
approvalPolicy: "never",
includeCollaborationModeInstructions: false,
environment: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
HOME: "/isolated/home",
CODEX_HOME: "/isolated/codex-home",
LANG: "C.UTF-8",
},
transportFactory: () => bundle.transport,
requireProviderSessionIdentity: true,
});
let session: Awaited<ReturnType<typeof driver.openSession>> | null = null;
try {
session = await driver.openSession({
runId: "run-goal-harness-autostart",
normalizedSessionId: "normalized-goal-harness-autostart",
workingDirectory: tmpdir(),
});
const observed: Array<{ eventType: string }> = [];
const turnStarted = Promise.race([
(async () => {
for await (const event of session!.events()) {
observed.push(event);
if (event.eventType === "turn.started" && event.turnId === (autocontinue ? "provider-goal-turn-2" : "provider-goal-turn-1")) return event;
if (event.eventType === "session.failed") {
throw new Error(`goal autostart failed: ${JSON.stringify(event.payload)}`);
}
}
throw new Error("goal autostart event stream closed");
})(),
new Promise<never>((_resolve, reject) => {
setTimeout(() => reject(new Error("goal autostart timed out")), 5_000);
}),
]);
await expect(
session.goal?.({
action: "set",
objective: "Finish the durable goal harness test.",
status: "active",
requestId: "goal-harness-autostart",
}),
).resolves.toMatchObject({ status: "active" });
await expect(turnStarted).resolves.toMatchObject({
eventType: "turn.started",
turnId: autocontinue ? "provider-goal-turn-2" : "provider-goal-turn-1",
});
expect(observed.some((event) => event.eventType === "session.failed")).toBe(false);
} finally {
await session?.close();
await bundle.transport.close();
await rm(stateDirectory, { recursive: true, force: true });
}
expect(bundle.evidence()).toMatchObject({
runnerExited: true,
runnerExitCode: 0,
});
}, 30_000);
it("continues rehydrating events after the committed-event window slides", async () => {
const stateDirectory = await mkdtemp(
join(tmpdir(), "runnerd-sliding-event-window-"),
@ -3056,7 +3350,7 @@ it("cold-restores a suspended provider session under its durable run binding", a
}
}, 30_000);
async function verifyLiveRunnerAdoption(mismatchedCheckpoint: boolean) {
async function verifyLiveRunnerAdoption(mismatchedCheckpoint: boolean, goalMidTurn = false) {
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-live-adopt-"));
const server = createServer();
let authority: DurablePrpControlPlane | null = null;
@ -3093,7 +3387,7 @@ async function verifyLiveRunnerAdoption(mismatchedCheckpoint: boolean) {
const sharedOptions = {
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodex,
codexArgs: fakeCodexArgs(stateDirectory),
codexArgs: fakeCodexArgs(stateDirectory, ...(goalMidTurn ? ["--goal-autostart", "--goal-item-trigger", join(stateDirectory, "emit-goal-item")] : [])),
stateDirectory,
prpIdentity: identity,
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 },
@ -3122,8 +3416,24 @@ async function verifyLiveRunnerAdoption(mismatchedCheckpoint: boolean) {
runnerPid = first.evidence().runnerPid;
expect(runnerPid).toEqual(expect.any(Number));
if (goalMidTurn) {
await first.transport.request("thread/goal/set", { objective: "Recover a live goal", status: "active" });
for await (const event of first.transport.notifications()) {
if (event.method === "turn/started") break;
}
}
await first.detachControllerForRestart();
expect(() => process.kill(runnerPid!, 0)).not.toThrow();
if (goalMidTurn) {
await writeFile(join(stateDirectory, "emit-goal-item"), "emit");
// runnerd need not poll the provider into its PRP outbox while disconnected.
// Wait for flushed provider output, not a platform-dependent final poll
// racing the disconnect. Adoption must still bind that buffered item.
await vi.waitFor(async () => {
expect(await readFile(join(stateDirectory, "emit-goal-item.sent"), "utf8")).toBe("sent");
}, { timeout: 5_000 });
}
const controlPlaneStatePath = join(
stateDirectory,
@ -3191,6 +3501,18 @@ async function verifyLiveRunnerAdoption(mismatchedCheckpoint: boolean) {
}),
);
expect(adopted.evidence().runnerPid).toBe(runnerPid);
if (goalMidTurn) {
const observed = await Promise.race([
(async () => {
for await (const notification of adopted!.transport.notifications()) {
if (notification.method === "item/started") return notification;
}
throw new Error("recovered goal item was lost");
})(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("recovered item timed out")), 5_000)),
]);
expect(observed.params).toMatchObject({ threadId: "codex-thread-1", turnId: "provider-goal-turn-1" });
}
expect(duplicateLauncher).not.toHaveBeenCalled();
expect(adopted.evidence().diagnostics).toContain(
`adopted runner ${runnerPid} authenticated to its durable PRP authority`,
@ -3232,6 +3554,8 @@ it(
30_000,
);
it("binds buffered mid-goal items only after the authenticated recovery snapshot", () => verifyLiveRunnerAdoption(false, true), 30_000);
it("surfaces a runner exit while provider-ingress readiness is still pending", async () => {
const neverReady = new Promise<void>(() => undefined);
const bundle = createCapabilityRunnerdCodexTransport({

View File

@ -672,6 +672,7 @@ async function awaitRunnerSuspensionBarrier(input: {
return false;
}
function bridgedCodexQuestionParams(
request: Record<string, unknown>,
method: string,
@ -953,6 +954,8 @@ export interface CapabilityRunnerdCodexTransportOptions {
runnerRuntimeContext?: NativeRuntimeContextSnapshot | null;
/** Root path visible to runnerd when it is not on the Paperclip host. */
runnerFilesystemRoot?: string;
/** Workspace cwd to retain when a local provider session is reopened. */
resumeWorkingDirectory?: string;
/**
* The provider process is already confined by a sandbox execution target.
* Codex must use its explicit external-sandbox policy because container
@ -1074,6 +1077,38 @@ export function unwrapRunnerdProviderNotification(
return notifications.at(-1) ?? record(input);
}
export function latestRunnerdSessionReadiness(
events: readonly unknown[],
): Record<string, unknown> | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = record(events[index]);
if (
event.eventType !== "harness.ready" &&
event.eventType !== "session.started" &&
event.eventType !== "session.resumed"
) continue;
return record(record(record(event.envelope).payload).payload);
}
return null;
}
export function unseenRunnerdCommittedEvents<
T extends { sourceSeq: number },
>(events: readonly T[], lastSourceSeq: number): T[] {
const unseen = events.filter((event) => event.sourceSeq > lastSourceSeq);
if (unseen.length === 0) return [];
let expectedSourceSeq = lastSourceSeq + 1;
for (const event of unseen) {
if (event.sourceSeq !== expectedSourceSeq) {
throw new Error(
`provider_notification_window_exceeded: expected source sequence ${expectedSourceSeq}, received ${event.sourceSeq}`,
);
}
expectedSourceSeq += 1;
}
return unseen;
}
export function expandRunnerdCanonicalNotifications(
method: string,
input: unknown,
@ -1083,6 +1118,41 @@ export function expandRunnerdCanonicalNotifications(
return payload.events.map((event) => ({ method, params: record(event) }));
}
export function runnerdCanonicalNotificationMethod(
eventType: string,
payload: Record<string, unknown>,
): string | undefined {
// An empty open/resume snapshot is already returned by thread/goal/get and
// is not a provider-side clear transition. Do not insert a synthetic clear
// ahead of the first real turn notification.
if (eventType === "session.goal.snapshot" && payload.goal === null) {
return undefined;
}
return (
{
"turn.started": "turn/started",
"item.started": "item/started",
"item.delta": "item/agentMessage/delta",
"item.completed": "item/completed",
"turn.completed": "turn/completed",
"turn.failed": "turn/completed",
"turn.interrupted": "turn/completed",
"turn.cancelled": "turn/completed",
"usage.reported": "thread/tokenUsage/updated",
"plan.updated": "turn/plan/updated",
"workspace.change.updated": "paperclip/workspaceChange/updated",
"run.result.proposed": "paperclip/runResult",
"session.goal.snapshot": "thread/goal/updated",
"session.goal.updated": "thread/goal/updated",
"session.goal.cleared": "thread/goal/cleared",
"session.updated":
payload.status === "budget_reached"
? "provider/budgetReached"
: "provider/sessionUpdated",
} as Record<string, string>
)[eventType];
}
export function resolveRunnerdSessionIdentity(input: unknown): {
processId: number | null;
threadId: string | null;
@ -1178,6 +1248,60 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
function appServerThreadGoal(
value: unknown,
threadId: string,
): Record<string, unknown> | null {
const goal = record(value);
const objective = typeof goal.objective === "string"
? goal.objective.trim()
: "";
const rawStatus = typeof goal.status === "string" ? goal.status : "";
const status = rawStatus === "usage_limited"
? "usageLimited"
: rawStatus === "budget_limited"
? "budgetLimited"
: rawStatus;
if (
objective.length === 0 ||
![
"active",
"paused",
"blocked",
"limited",
"usageLimited",
"budgetLimited",
"complete",
].includes(status)
) return null;
const epochSeconds = (timestamp: unknown): number => {
if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
return timestamp > 10_000_000_000 ? timestamp / 1_000 : timestamp;
}
if (typeof timestamp !== "string") return 0;
const milliseconds = Date.parse(timestamp);
return Number.isFinite(milliseconds) ? milliseconds / 1_000 : 0;
};
return {
threadId,
objective,
status,
tokenBudget:
typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
tokensUsed: typeof goal.tokensUsed === "number" ? goal.tokensUsed : 0,
timeUsedSeconds:
typeof goal.timeUsedSeconds === "number"
? goal.timeUsedSeconds
: typeof goal.elapsedSeconds === "number"
? goal.elapsedSeconds
: 0,
createdAt: epochSeconds(goal.createdAt),
updatedAt: epochSeconds(goal.updatedAt),
};
}
type PendingTraceRehydration = {
sourceEventId: string;
eventType: string;
@ -1514,6 +1638,21 @@ export function rehydrateRunnerdWorkspaceChangeNotification(
};
}
export function rehydrateRunnerdGoalNotification(
rawParams: Record<string, unknown>,
openedThreadId: string,
method: "thread/goal/updated" | "thread/goal/cleared",
): Record<string, unknown> {
if (method === "thread/goal/cleared") {
return { ...rawParams, threadId: openedThreadId };
}
return {
...rawParams,
threadId: openedThreadId,
goal: appServerThreadGoal(rawParams.goal, openedThreadId),
};
}
function commandDigest(value: unknown): string {
return `sha256:${createHash("sha256").update(durableRecoveryInternals.canonicalJson(value)).digest("hex")}`;
}
@ -1999,6 +2138,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#pump: NodeJS.Timeout | null = null;
#eventSourceSeq = 0;
#deferredTurnStartEvents: DurableRecoveryCommittedEvent[] = [];
#recoveryTurnBindingPending = false;
#threadId = "";
#sessionId: string | null = null;
#providerIdentity: Record<string, unknown> | null = null;
@ -2169,7 +2309,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
return {};
}
if (method === "thread/read") {
if (this.#core === null) await this.#resume();
if (this.#core === null) {
this.#recoveryTurnBindingPending = true;
await this.#resume();
}
// Ask the authenticated runner for its live provider snapshot rather
// than reading its filesystem. This both supports remote process owners
// and proves any identity restored after PRP event compaction before the
@ -2231,6 +2374,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
});
}
}
this.#recoveryTurnBindingPending = false;
this.#pumpEvents();
return {
thread: {
id: this.#threadId,
@ -2248,6 +2393,26 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
},
};
}
if (method === "thread/goal/get") {
const result = await this.#commandResult("session.goal.get", params);
return {
goal: appServerThreadGoal(result.goal, this.#threadId),
};
}
if (method === "thread/goal/set") {
const result = await this.#commandResult("session.goal.set", params);
const snapshot = record(result.snapshot);
return {
goal: appServerThreadGoal(
snapshot.goal ?? result.goal,
this.#threadId,
),
};
}
if (method === "thread/goal/clear") {
await this.#commandResult("session.goal.clear", params);
return {};
}
if (method === "session/budget/increase") {
await this.#command("session.budget.increase", params);
return {};
@ -3956,12 +4121,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
);
}
#pumpEvents(): void {
this.#flushPendingTraceRehydrations();
const events = this.#core?.store.state.committedEvents ?? [];
for (;;) {
const deferredEvent =
!this.#turnStartResponsePending || this.#expectedProviderTurnId !== null
!this.#recoveryTurnBindingPending &&
(!this.#turnStartResponsePending || this.#expectedProviderTurnId !== null)
? this.#deferredTurnStartEvents[0]
: undefined;
const event =
@ -3974,6 +4141,21 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
`PRP provider event window advanced past source sequence ${this.#eventSourceSeq + 1}`,
);
}
if (this.#recoveryTurnBindingPending && ![
"harness.ready", "session.started", "session.resumed",
].includes(event.eventType)) {
// Reconnection can deliver mid-turn items before thread/read obtains
// the authenticated active provider turn. Retain canonical events,
// not notifications rehydrated with an empty/stale turn identity.
// Identity events still advance startup; command results are consumed
// independently, so session.snapshot cannot deadlock behind this gate.
if (this.#deferredTurnStartEvents.length >= 4_096) {
throw new Error("recovery produced too many events before its turn binding");
}
this.#eventSourceSeq = event.sourceSeq;
this.#deferredTurnStartEvents.push(structuredClone(event));
continue;
}
const eventPayload = record(event.envelope.payload).payload;
const turnStartWhileCommandResultPending =
this.#turnStartResponsePending &&
@ -4107,26 +4289,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
continue;
}
const sessionUpdatePayload = record(eventPayload);
const canonicalMethod = (
{
"turn.started": "turn/started",
"item.started": "item/started",
"item.delta": "item/agentMessage/delta",
"item.completed": "item/completed",
"turn.completed": "turn/completed",
"turn.failed": "turn/completed",
"turn.interrupted": "turn/completed",
"turn.cancelled": "turn/completed",
"usage.reported": "thread/tokenUsage/updated",
"plan.updated": "turn/plan/updated",
"workspace.change.updated": "paperclip/workspaceChange/updated",
"run.result.proposed": "paperclip/runResult",
"session.updated":
sessionUpdatePayload.status === "budget_reached"
? "provider/budgetReached"
: "provider/sessionUpdated",
} as Record<string, string>
)[event.eventType];
const canonicalMethod = runnerdCanonicalNotificationMethod(
event.eventType,
sessionUpdatePayload,
);
const notifications =
event.eventType === "provider.event"
? unwrapRunnerdProviderNotifications(eventPayload)
@ -4167,38 +4333,44 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#threadId,
this.#turnId,
)
: method === "paperclip/workspaceChange/updated"
? rehydrateRunnerdWorkspaceChangeNotification(
rawParams,
this.#threadId,
this.#turnId,
)
: method === "paperclip/runResult"
? rehydrateRunnerdResultNotification(
rawParams,
this.#threadId,
this.#turnId,
typeof event.envelope.itemId === "string"
? event.envelope.itemId
: "semantic-result",
)
: event.eventType !== "provider.event" &&
(method === "item/started" || method === "item/completed")
? rehydrateRunnerdItemNotification(
rawParams,
this.#threadId,
this.#turnId,
)
: event.eventType !== "provider.event" &&
(method === "turn/started" ||
method === "turn/completed")
? rehydrateRunnerdTurnNotification(
rawParams,
this.#threadId,
this.#turnId,
method,
)
: rawParams;
: method === "paperclip/workspaceChange/updated"
? rehydrateRunnerdWorkspaceChangeNotification(
rawParams,
this.#threadId,
this.#turnId,
)
: method === "paperclip/runResult"
? rehydrateRunnerdResultNotification(
rawParams,
this.#threadId,
this.#turnId,
typeof event.envelope.itemId === "string"
? event.envelope.itemId
: "semantic-result",
)
: method === "thread/goal/updated" ||
method === "thread/goal/cleared"
? rehydrateRunnerdGoalNotification(
rawParams,
this.#threadId,
method,
)
: event.eventType !== "provider.event" &&
(method === "item/started" || method === "item/completed")
? rehydrateRunnerdItemNotification(
rawParams,
this.#threadId,
this.#turnId,
)
: event.eventType !== "provider.event" &&
(method === "turn/started" || method === "turn/completed")
? rehydrateRunnerdTurnNotification(
rawParams,
this.#threadId,
this.#turnId,
method,
)
: rawParams;
if (
params.turnId === undefined &&
typeof event.envelope.turnId === "string"

View File

@ -195,6 +195,562 @@ function highestContiguous(events: PrpEvent[]): number {
}
describe("executeNativeSession recovery", () => {
it.each((["complete", "paused", "blocked", "limited", "usageLimited", "budgetLimited"] as const)
.flatMap((status) => [false, true].map((snapshotBeforeUpdate) => ({ status, snapshotBeforeUpdate }))))(
"handles a new chat turn instead of completing it from an existing $status goal (snapshot: $snapshotBeforeUpdate)", async ({ status, snapshotBeforeUpdate }) => {
const oldGoal = {
threadId: "provider-recovery",
objective: "Say hello",
status,
tokenBudget: null,
tokensUsed: 500,
timeUsedSeconds: 2,
createdAt: Date.parse("2026-08-09T00:00:00.000Z"),
updatedAt: Date.parse("2026-08-09T00:00:02.000Z"),
};
const reply = { ...result, summary: "Said bye in response to the new message." };
const capabilities = {
resume: true, typedEvents: true, steering: false,
interruption: true, structuredResult: true,
};
const checkpoint: PersistedNativeSession = {
backendKind: "mock",
sessionId: "driver-recovery",
identity,
providerSessionId: oldGoal.threadId,
activeTurnId: null,
semanticResult: null,
terminal: null,
terminalTurns: [],
pendingRuntimeRequests: [],
goal: { ...oldGoal, createdAt: oldGoal.createdAt / 1000, updatedAt: oldGoal.updatedAt / 1000 },
};
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => ({ turnId: "turn-recovery" }));
const goal = vi.fn(async () => oldGoal);
const session: NativeSession = {
identity: () => identity,
async capabilities() { return capabilities; },
async *events() {
let seq = 0;
// The resume snapshot is durable UI state, not work for this prompt.
if (snapshotBeforeUpdate) yield runnerEvent(++seq, "session.goal.snapshot", {
goal: {
...oldGoal,
createdAt: new Date(oldGoal.createdAt).toISOString(),
elapsedSeconds: oldGoal.timeUsedSeconds,
},
workingNow: false,
});
// Codex replays the unchanged goal as an update during resume too.
// Usage-only changes do not make an inactive goal own a new prompt.
yield runnerEvent(++seq, "session.goal.updated", {
goal: {
...oldGoal,
createdAt: new Date(oldGoal.createdAt).toISOString(),
tokensUsed: 600,
updatedAt: new Date(oldGoal.updatedAt + 1000).toISOString(),
},
workingNow: false,
});
yield runnerEvent(++seq, "turn.started");
yield runnerEvent(++seq, "run.result.proposed", reply);
yield runnerEvent(++seq, "turn.completed");
},
startTurn,
goal,
async result() { return { result: reply, terminal, turnId: "turn-recovery" }; },
async snapshot() { return checkpoint; },
async close() {},
};
const appended: PrpEvent[] = [];
const completed = await executeNativeSession({
input: { ...input, task: { ...input.task, prompt: "Say bye" } },
backend: {
async descriptor() {
return { kind: "mock", name: "chat-after-goal", version: "1", capabilities };
},
async openSession() { throw new Error("must resume the same provider session"); },
async recoverSession() { return { recovered: true, session }; },
},
persistedSession: checkpoint,
controlPlane: {
async openRun() {},
async checkpointSession() {},
async appendEvent(event) {
appended.push(event);
return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" };
},
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
async completeRun() {},
},
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
timeoutMs: 1000,
});
expect(startTurn).toHaveBeenCalledOnce();
expect(JSON.parse(startTurn.mock.calls[0]![0]!.message.text).task.prompt).toBe("Say bye");
expect(goal).not.toHaveBeenCalled();
expect(completed.providerSessionId).toBe(oldGoal.threadId);
expect(completed.result).toEqual(reply);
expect(appended.map((event) => event.eventType)).toEqual([
...(snapshotBeforeUpdate ? ["session.goal.snapshot"] : []),
"session.goal.updated", "turn.started", "run.result.proposed", "turn.completed",
"run.result.accepted", "run.terminal",
]);
});
it("applies a session goal control without starting an ordinary turn", async () => {
const activeGoal = {
threadId: "provider-recovery",
objective: "Verify goal mode",
status: "active" as const,
tokenBudget: 12_000,
tokensUsed: 100,
timeUsedSeconds: 1,
createdAt: Date.parse("2026-08-09T00:00:00.000Z"),
updatedAt: Date.parse("2026-08-09T00:00:01.000Z"),
};
const completeGoal = {
...activeGoal,
status: "complete" as const,
tokensUsed: 500,
timeUsedSeconds: 2,
updatedAt: Date.parse("2026-08-09T00:00:02.000Z"),
};
const startTurn = vi.fn(async () => ({ turnId: "turn-recovery" }));
const goal = vi.fn(async (operation: Parameters<NonNullable<NativeSession["goal"]>>[0]) =>
operation.action === "get" ? completeGoal : activeGoal,
);
const session: NativeSession = {
identity: () => identity,
async capabilities() {
return {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
};
},
async *events() {
yield runnerEvent(1, "session.goal.snapshot", {
goal: null,
workingNow: false,
});
yield runnerEvent(2, "session.goal.updated", {
requestId: "goal-create",
goal: {
objective: activeGoal.objective,
status: activeGoal.status,
tokenBudget: activeGoal.tokenBudget,
tokensUsed: activeGoal.tokensUsed,
elapsedSeconds: activeGoal.timeUsedSeconds,
},
workingNow: false,
});
yield runnerEvent(3, "turn.started");
yield runnerEvent(4, "run.result.proposed", result);
yield runnerEvent(5, "turn.completed");
yield runnerEvent(6, "session.goal.snapshot", {
goal: {
objective: completeGoal.objective,
status: completeGoal.status,
tokenBudget: completeGoal.tokenBudget,
tokensUsed: completeGoal.tokensUsed,
elapsedSeconds: completeGoal.timeUsedSeconds,
},
workingNow: false,
});
},
startTurn,
goal,
async result() {
return null;
},
async snapshot() {
return {
backendKind: "mock",
sessionId: "driver-recovery",
identity,
providerSessionId: "provider-recovery",
cursor: null,
activeTurnId: null,
pendingRuntimeRequests: [],
goal: completeGoal,
lineage: [],
};
},
async close() {},
};
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "goal-backend",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
async openSession() {
return session;
},
};
const appended: PrpEvent[] = [];
const port: ControlPlanePort = {
async openRun() {},
async checkpointSession() {},
async appendEvent(event) {
appended.push(event);
return {
cursor: event.sourceSeq,
highestContiguousSourceSeq: event.sourceSeq,
disposition: "committed",
};
},
async replayEvents() {
return { events: [], highestContiguousSourceSeq: 0 };
},
async completeRun() {},
};
const completed = await executeNativeSession({
input,
backend,
controlPlane: port,
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
sessionGoalControl: {
requestId: "goal-create",
action: "create",
objective: activeGoal.objective,
tokenBudget: activeGoal.tokenBudget,
},
});
expect(startTurn).not.toHaveBeenCalled();
expect(goal).toHaveBeenNthCalledWith(1, {
action: "set",
objective: activeGoal.objective,
status: "active",
requestId: "goal-create",
tokenBudget: activeGoal.tokenBudget,
});
expect(goal).toHaveBeenNthCalledWith(2, { action: "get" });
expect(appended.map((event) => event.eventType)).toContain("session.goal.snapshot");
expect(completed.result).toMatchObject({
reportedWorkDisposition: "done",
summary: result.summary,
completionClaim: { objectiveSatisfied: true },
});
});
it.each((["complete", "paused", "blocked", "limited", "usageLimited", "budgetLimited"] as const)
.flatMap((status) => [false, true].map((lateGoal) => ({ status, lateGoal }))))(
"reconciles an out-of-band $status goal (after semantic result: $lateGoal)", async ({ status, lateGoal }) => {
const activeGoal = {
threadId: "provider-agent-goal",
objective: "Finish autonomous work",
status: "active" as const,
tokenBudget: null,
tokensUsed: 100,
timeUsedSeconds: 1,
createdAt: Date.parse("2026-08-09T00:00:00.000Z"),
updatedAt: Date.parse("2026-08-09T00:00:01.000Z"),
};
const completeGoal = {
...activeGoal,
status,
tokensUsed: 500,
timeUsedSeconds: 2,
updatedAt: Date.parse("2026-08-09T00:00:02.000Z"),
};
const startTurn = vi.fn(async () => ({ turnId: "turn-agent-goal" }));
const goal = vi.fn(async () => completeGoal);
const session: NativeSession = {
identity: () => identity,
async capabilities() {
return {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
};
},
async *events() {
if (lateGoal) yield runnerEvent(1, "run.result.proposed", result);
yield runnerEvent(lateGoal ? 2 : 1, "session.goal.updated", {
goal: {
objective: activeGoal.objective,
status: activeGoal.status,
tokenBudget: activeGoal.tokenBudget,
tokensUsed: activeGoal.tokensUsed,
elapsedSeconds: activeGoal.timeUsedSeconds,
},
workingNow: true,
});
if (!lateGoal) yield runnerEvent(2, "run.result.proposed", result);
// A newly observed goal must revoke the ordinary semantic-result
// timeout, including when the proposal arrived first.
await new Promise((resolve) => setTimeout(resolve, 25));
yield runnerEvent(3, "session.goal.updated", {
goal: {
objective: completeGoal.objective,
status: completeGoal.status,
tokenBudget: completeGoal.tokenBudget,
tokensUsed: completeGoal.tokensUsed,
elapsedSeconds: completeGoal.timeUsedSeconds,
},
workingNow: true,
});
yield runnerEvent(4, "turn.completed");
},
startTurn,
goal,
async result() {
return null;
},
async snapshot() {
return {
backendKind: "mock",
sessionId: "driver-agent-goal",
identity,
providerSessionId: activeGoal.threadId,
cursor: null,
activeTurnId: null,
pendingRuntimeRequests: [],
goal: activeGoal,
lineage: [],
};
},
async close() {},
};
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "agent-goal-backend",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
async openSession() {
return session;
},
};
const appended: PrpEvent[] = [];
const port: ControlPlanePort = {
async openRun() {},
async checkpointSession() {},
async appendEvent(event) {
appended.push(event);
return {
cursor: event.sourceSeq,
highestContiguousSourceSeq: event.sourceSeq,
disposition: "committed",
};
},
async replayEvents() {
return { events: [], highestContiguousSourceSeq: 0 };
},
async completeRun() {},
};
const completed = await executeNativeSession({
input,
backend,
controlPlane: port,
runnerInstanceId: "runner-agent-goal",
controlPlaneInstanceId: "control-agent-goal",
semanticResultTerminalGraceMs: 5,
});
expect(startTurn).toHaveBeenCalledOnce();
expect(goal).not.toHaveBeenCalled();
expect(appended.map((event) => event.eventType)).toEqual([
...(lateGoal ? ["run.result.proposed", "session.goal.updated"] : ["session.goal.updated", "run.result.proposed"]),
"session.goal.updated",
"turn.completed",
"run.result.accepted",
"run.terminal",
]);
expect(completed.result).toMatchObject({
reportedWorkDisposition: status === "complete" ? "done" : status === "blocked" ? "blocked" : "yielded",
...(status === "complete" ? { summary: result.summary } : {}),
completionClaim: { objectiveSatisfied: status === "complete" },
});
});
it.each([
{ keepSessionOpen: false, recoveryOnly: false },
{ keepSessionOpen: true, recoveryOnly: false },
{ keepSessionOpen: false, recoveryOnly: true },
{ keepSessionOpen: true, recoveryOnly: true },
].flatMap((options) => [false, true].map((cleared) => ({ ...options, cleared }))))("reconciles goal recovery without replaying completed controls (warm=$keepSessionOpen, recovery=$recoveryOnly, cleared=$cleared)", async ({ keepSessionOpen, recoveryOnly, cleared }) => {
const pausedGoal = {
threadId: "provider-recovery",
objective: "Verify recovered goal control",
status: recoveryOnly ? "complete" as const : "paused" as const,
tokenBudget: null,
tokensUsed: 250,
timeUsedSeconds: 2,
createdAt: Date.parse("2026-08-09T00:00:00.000Z"),
updatedAt: Date.parse("2026-08-09T00:00:02.000Z"),
};
const startTurn = vi.fn(async () => ({ turnId: "turn-recovery" }));
const goal = vi.fn(async () => cleared ? null : pausedGoal);
const requestId = recoveryOnly ? `recovery_${input.binding.runId}` : cleared ? "goal-clear" : "goal-pause";
const close = vi.fn(async () => {});
let snapshotCount = 0;
const session: NativeSession = {
identity: () => identity,
async capabilities() {
return {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
};
},
async *events() {
yield runnerEvent(1, cleared ? "session.goal.cleared" : "session.goal.updated", {
requestId,
goal: cleared ? null : {
objective: pausedGoal.objective,
status: pausedGoal.status,
tokenBudget: pausedGoal.tokenBudget,
tokensUsed: pausedGoal.tokensUsed,
elapsedSeconds: pausedGoal.timeUsedSeconds,
},
workingNow: !recoveryOnly,
});
yield runnerEvent(2, "turn.completed");
yield runnerEvent(3, "session.goal.snapshot", {
goal: cleared ? null : {
objective: pausedGoal.objective,
status: pausedGoal.status,
tokenBudget: pausedGoal.tokenBudget,
tokensUsed: pausedGoal.tokensUsed,
elapsedSeconds: pausedGoal.timeUsedSeconds,
},
workingNow: false,
});
},
startTurn,
goal,
async result() {
return null;
},
async snapshot() {
snapshotCount += 1;
return {
backendKind: "mock",
sessionId: "driver-recovery",
identity,
providerSessionId: "provider-recovery",
cursor: null,
activeTurnId: !recoveryOnly && snapshotCount === 1 ? "turn-recovery" : null,
pendingRuntimeRequests: [],
goal: cleared ? null : pausedGoal,
lineage: [],
};
},
close,
};
const persistedSession: PersistedNativeSession = {
backendKind: "mock",
sessionId: "driver-recovery",
identity,
providerSessionId: "provider-recovery",
cursor: null,
activeTurnId: "turn-recovery",
pendingRuntimeRequests: [],
goal: { ...pausedGoal, status: "active" },
lineage: [],
};
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "goal-recovery-backend",
version: "1",
capabilities: {
resume: true,
typedEvents: true,
steering: false,
interruption: true,
structuredResult: true,
},
};
},
async openSession() {
throw new Error("fresh session must not be opened");
},
async recoverSession() {
return { recovered: true, session };
},
};
const port: ControlPlanePort = {
async openRun() {},
async checkpointSession() {},
async appendEvent(event) {
return {
cursor: event.sourceSeq,
highestContiguousSourceSeq: event.sourceSeq,
disposition: "committed",
};
},
async replayEvents() {
return { events: [], highestContiguousSourceSeq: 0 };
},
async completeRun() {},
};
const completed = await executeNativeSession({
input,
backend,
controlPlane: port,
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
persistedSession,
keepSessionOpen,
requireSessionCloseBeforeReturn: true,
resumeSessionGoalHeartbeat: recoveryOnly,
sessionGoalControl: recoveryOnly ? null : {
requestId,
action: cleared ? "clear" : "pause",
},
});
expect(startTurn).not.toHaveBeenCalled();
expect(goal).toHaveBeenNthCalledWith(1, {
action: recoveryOnly ? "get" : cleared ? "clear" : "pause",
requestId,
});
expect(goal).toHaveBeenCalledTimes(cleared && !recoveryOnly ? 2 : 1);
if (cleared && !recoveryOnly) {
expect(goal).toHaveBeenNthCalledWith(2, { action: "get" });
}
expect(close).toHaveBeenCalledTimes(1);
expect(completed.result).toMatchObject({
reportedWorkDisposition: recoveryOnly && !cleared ? "done" : "yielded",
completionClaim: { objectiveSatisfied: recoveryOnly && !cleared },
});
});
it("keeps governed-wait discovery synchronous", () => {
type GovernedWaitResolver = NonNullable<
ExecuteNativeSessionOptions["resolveGovernedWait"]

View File

@ -17,12 +17,13 @@ import type {
NativeSessionBackend,
} from "./contracts/native-session-backend.js";
import type { PersistedNativeSession } from "./contracts/native-session-backend.js";
import {
validatePrpStructuredRunResult,
type PrpEvent,
type PrpStructuredRunResult,
type PrpTerminalState,
import type { HarnessThreadGoal } from "./contracts/harness-driver.js";
import type {
PrpEvent,
PrpStructuredRunResult,
PrpTerminalState,
} from "./protocol/replay-contract.js";
import { validatePrpStructuredRunResult } from "./protocol/replay-contract.js";
import { parsePaperclipQuestionSet } from "./contracts/question-set.js";
export const DEFAULT_NATIVE_RUNTIME_INPUT_LIVE_WINDOW_MS = 120_000;
@ -73,6 +74,13 @@ interface QuarantinedSessionCleanup {
const quarantinedSessionCleanups = new Set<QuarantinedSessionCleanup>();
export interface NativeSessionGoalControl {
requestId: string;
action: "create" | "edit" | "replace" | "pause" | "resume" | "clear";
objective?: string;
tokenBudget?: number | null;
}
export interface ExecuteNativeSessionOptions {
input: NativeExecutionInput;
backend: NativeSessionBackend;
@ -94,6 +102,10 @@ export interface ExecuteNativeSessionOptions {
existingSession?: NativeSession;
persistedSession?: PersistedNativeSession | null;
keepSessionOpen?: boolean;
/** Apply a structured session-goal control instead of starting an ordinary turn. */
sessionGoalControl?: NativeSessionGoalControl | null;
/** Resume the active durable session goal after a bounded heartbeat rollover. */
resumeSessionGoalHeartbeat?: boolean;
/**
* Wait for the backend's close contract before returning a durable result.
* Use this only for backends whose close path is internally bounded and
@ -700,6 +712,7 @@ async function retryQuarantinedSessionCleanups(
async function consumeTurn(
session: NativeSession,
controlPlane: ControlPlanePort,
input: NativeExecutionInput,
timeoutMs: number,
runtimeInputLiveWindowMs: number,
semanticResultTerminalGraceMs: number,
@ -708,6 +721,11 @@ async function consumeTurn(
quarantineSession: (reason: string) => void,
resolveGovernedWait?: ExecuteNativeSessionOptions["resolveGovernedWait"],
externalSignal?: AbortSignal,
sessionGoal?: {
input: NativeExecutionInput;
requestId: string;
},
initialGoal?: HarnessThreadGoal | null,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
let semanticResultTimer: ReturnType<typeof setTimeout> | undefined;
@ -756,6 +774,11 @@ async function consumeTurn(
let eventCount = 0;
let highestContiguousSourceSeq = 0;
let governedResult: PrpStructuredRunResult | null = null;
let semanticResultProposal: PrpStructuredRunResult | null = null;
let sessionGoalObserved = sessionGoal !== undefined;
let previousGoal = initialGoal ?? null;
let goalControlObserved = false;
let latestSessionGoal: HarnessThreadGoal | null = null;
let resultSource: "semantic_result" | "governed_wait" | null = null;
let semanticResultEvent: PrpEvent | null = null;
let semanticResultDeadline: Promise<
@ -848,6 +871,36 @@ async function consumeTurn(
highestContiguousSourceSeq,
receipt.highestContiguousSourceSeq,
);
const eventGoal = goalFromEvent(event);
const goalChanged = eventGoal !== undefined &&
goalLifecycleFingerprint(eventGoal) !== goalLifecycleFingerprint(previousGoal);
if (eventGoal !== undefined) previousGoal = eventGoal;
if (
eventGoal !== undefined && eventGoal !== null &&
(sessionGoalObserved || goalStatus(eventGoal) === "active" ||
(event.eventType === "session.goal.updated" && goalChanged))
) {
// An inactive resume snapshot describes the previous goal, not the
// work requested by a new ordinary prompt. Only an explicit goal
// control, an active goal, or a new lifecycle change owns this run's
// lifetime. Resume can replay unchanged updates as well as snapshots;
// append both for the UI without mistaking them for new goal work.
sessionGoalObserved = true;
latestSessionGoal = eventGoal;
// A harness-created goal may arrive after a semantic result proposal.
// The goal owns the durable lifetime; revoke the old grace deadline.
if (resultSource === "semantic_result") {
if (semanticResultTimer !== null) clearTimeout(semanticResultTimer);
semanticResultDeadline = null;
governedResult = null;
resultSource = null;
}
if (sessionGoal === undefined) goalControlObserved = true;
}
if (event.eventType === "run.result.proposed") {
const validation = validatePrpStructuredRunResult(event.payload);
if (validation.ok) semanticResultProposal = validation.result;
}
const request =
payload.request &&
typeof payload.request === "object" &&
@ -916,6 +969,7 @@ async function consumeTurn(
}
if (
governedResult === null &&
!sessionGoalObserved &&
event.eventType === "run.result.proposed"
) {
const validation = validatePrpStructuredRunResult(event.payload);
@ -949,7 +1003,7 @@ async function consumeTurn(
});
if (governedResult !== null) resultSource = "governed_wait";
}
if (governedResult !== null && !isTurnTerminal(event)) {
if (governedResult !== null && !isTurnTerminal(event) && !sessionGoalObserved) {
if (resultSource === "semantic_result") {
// Give the provider a short grace to publish its final assistant
// message and terminal after the semantic tool returns. If no
@ -962,6 +1016,60 @@ async function consumeTurn(
"Paperclip parked this turn on a durable governed interaction.",
);
}
if (sessionGoalObserved) {
if (sessionGoal && payload.requestId === sessionGoal.requestId) {
goalControlObserved = true;
}
if (
goalControlObserved &&
eventGoal !== undefined &&
goalStatus(eventGoal) !== "active" &&
payload.workingNow !== true
) {
return {
event,
eventCount,
highestContiguousSourceSeq,
governedResult:
governedResult ??
(goalStatus(eventGoal) === "complete" ? semanticResultProposal : null) ??
sessionGoalResult(
input,
eventGoal,
`Provider session goal settled as ${goalStatus(eventGoal) ?? "cleared"}.`,
),
};
}
if (isTurnTerminal(event)) {
if (
latestSessionGoal !== null &&
goalStatus(latestSessionGoal) !== "active"
) {
return {
event,
eventCount,
highestContiguousSourceSeq,
governedResult:
governedResult ??
(goalStatus(latestSessionGoal) === "complete" ? semanticResultProposal : null) ??
sessionGoalResult(
input,
latestSessionGoal,
`Provider session goal settled as ${goalStatus(latestSessionGoal) ?? "cleared"}.`,
),
};
}
if (!session.goal) throw new Error("native_session_goal_unavailable");
const authoritativeGoal = await session.goal({ action: "get" });
// `goal(get)` emits an authoritative goal snapshot. Keep consuming
// until that snapshot is durably appended so the server projection
// cannot lag behind the synthetic heartbeat result.
if (goalStatus(authoritativeGoal) === "active") continue;
goalControlObserved = true;
latestSessionGoal = authoritativeGoal;
continue;
}
}
if (isTurnTerminal(event)) {
return {
event,
@ -1103,6 +1211,164 @@ async function consumeTurn(
}
}
function goalLifecycleFingerprint(goal: HarnessThreadGoal | null): string {
if (goal === null) return "cleared";
// Provider checkpoints can use seconds while normalized events use ISO
// timestamps (parsed as milliseconds). Usage/timing updates alone must not
// turn a completed or paused goal into ownership of an ordinary chat run.
const createdAt = goal.createdAt < 10_000_000_000
? goal.createdAt * 1_000 : goal.createdAt;
return canonicalJson({ objective: goal.objective, status: goal.status, createdAt });
}
function goalStatus(goal: HarnessThreadGoal | null):
| "active"
| "paused"
| "blocked"
| "limited"
| "usage_limited"
| "budget_limited"
| "complete"
| null {
if (!goal) return null;
return goal.status === "usageLimited"
? "usage_limited"
: goal.status === "budgetLimited"
? "budget_limited"
: goal.status;
}
function goalFromEvent(event: PrpEvent): HarnessThreadGoal | null | undefined {
if (
event.eventType !== "session.goal.snapshot" &&
event.eventType !== "session.goal.updated" &&
event.eventType !== "session.goal.cleared"
) return undefined;
if (event.eventType === "session.goal.cleared") return null;
const value = event.payload.goal;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (typeof record.objective !== "string" || typeof record.status !== "string") return null;
const providerStatus = record.status === "usage_limited"
? "usageLimited"
: record.status === "budget_limited"
? "budgetLimited"
: record.status;
if (!["active", "paused", "blocked", "limited", "usageLimited", "budgetLimited", "complete"].includes(providerStatus)) {
return null;
}
const epoch = (value: unknown): number => {
if (typeof value !== "string") return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
};
return {
threadId: "normalized",
objective: record.objective,
status: providerStatus as HarnessThreadGoal["status"],
tokenBudget: typeof record.tokenBudget === "number" ? record.tokenBudget : null,
tokensUsed: typeof record.tokensUsed === "number" ? record.tokensUsed : 0,
timeUsedSeconds: typeof record.elapsedSeconds === "number" ? record.elapsedSeconds : 0,
createdAt: epoch(record.createdAt),
updatedAt: epoch(record.updatedAt),
};
}
export async function applyNativeSessionGoalControl(
session: NativeSession,
control: NativeSessionGoalControl,
): Promise<HarnessThreadGoal | null> {
if (!session.goal) throw new Error("native_session_goal_unavailable");
if (control.action === "clear") {
return session.goal({ action: "clear", requestId: control.requestId });
}
if (control.action === "pause" || control.action === "resume") {
return session.goal({ action: control.action, requestId: control.requestId });
}
const objective = control.objective?.trim();
if (!objective) throw new Error(`native_session_goal_${control.action}_objective_required`);
if (control.action === "replace") {
await session.goal({ action: "clear", requestId: control.requestId });
}
let status: HarnessThreadGoal["status"] = "active";
if (control.action === "edit") {
const current = await session.goal({ action: "get" });
if (!current) throw new Error("native_session_goal_not_found");
status = current.status === "complete" ? "active" : current.status;
}
return session.goal({
action: "set",
objective,
status,
requestId: control.requestId,
...(control.tokenBudget !== undefined
? { tokenBudget: control.tokenBudget }
: {}),
});
}
function sessionGoalResult(
input: NativeExecutionInput,
goal: HarnessThreadGoal | null,
reason: string,
): PrpStructuredRunResult {
const status = goalStatus(goal);
const objective = goal?.objective ?? input.completionContract.contract.objective;
const disposition = status === "complete"
? "done"
: status === "blocked"
? "blocked"
: "yielded";
const result: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: disposition,
summary: status === "complete"
? `Session goal completed: ${objective}`
: status === "blocked"
? `Session goal blocked: ${objective}`
: `Session goal yielded (${status ?? "cleared"}): ${objective}`,
completionClaim: {
contractRevision: input.completionContract.contract.revision,
objectiveSatisfied: status === "complete",
criteria: input.completionContract.contract.criteria.map((criterion) => ({
criterionId: criterion.id,
status: status === "complete" ? "satisfied" : "unknown",
evidenceRefs: status === "complete" ? ["session-goal:complete"] : [],
explanation: `Provider session goal status: ${status ?? "cleared"}.`,
})),
remainingWork: status === "complete"
? []
: [{ description: reason, blocksCompletion: true }],
},
evidence: status === "complete"
? [{ kind: "session_goal_status", ref: "session-goal:complete" }]
: [],
verification: [],
attentionRequests: [],
artifacts: [],
...(disposition === "blocked"
? {
blocker: {
reasonCode: "session_goal_blocked",
owner: { kind: "agent", name: "session goal provider" },
unblockAction: reason,
scope: "current_track" as const,
},
}
: {}),
...(disposition === "yielded"
? {
continuation: {
kind: "same_agent" as const,
summary: reason,
idempotencyKey: `session-goal:${input.binding.issueId}:${status ?? "cleared"}`,
},
}
: {}),
};
return result;
}
function checkpointCursor(cursor: string | null | undefined): number {
if (cursor === undefined || cursor === null || cursor === "") return 0;
const parsed = Number(cursor);
@ -1735,6 +2001,9 @@ export async function executeNativeSession(
return activeClose;
};
let executionSucceeded = false;
let goalCheckpointRequiresSuspension = Boolean(
options.sessionGoalControl || options.resumeSessionGoalHeartbeat || persistedSession?.goal,
);
try {
// Ownership publication is part of the execution-owned lifetime. If the
// callback fails, the finally block below still quarantines and closes the
@ -1880,6 +2149,7 @@ export async function executeNativeSession(
? consumeTurn(
session,
options.controlPlane,
input,
options.timeoutMs ?? 900_000,
options.runtimeInputLiveWindowMs ??
DEFAULT_NATIVE_RUNTIME_INPUT_LIVE_WINDOW_MS,
@ -1892,6 +2162,13 @@ export async function executeNativeSession(
quarantineSession,
options.resolveGovernedWait,
consumptionAbort.signal,
options.sessionGoalControl || options.resumeSessionGoalHeartbeat
? {
input,
requestId: options.sessionGoalControl?.requestId ?? `recovery_${input.binding.runId}`,
}
: undefined,
recoveredSnapshot.goal,
)
: Promise.resolve({
event: recoveryTerminal,
@ -1907,13 +2184,31 @@ export async function executeNativeSession(
// that later rejection becomes process-fatal under Node's strict policy.
void consuming.catch(() => undefined);
try {
if (
const shouldStartFreshTurn =
!recovered ||
(!recoveredActiveTurnId &&
!adoptedDispositionTerminal &&
!checkpointedDispositionTerminal &&
!dispositionRecoveryStillOwned)
) {
!dispositionRecoveryStillOwned);
if (options.sessionGoalControl) {
// Explicit controls remain authoritative after controller loss. In
// particular, pause/clear/edit must reach a recovered session even
// while its provider turn is still active.
await applyNativeSessionGoalControl(session, options.sessionGoalControl);
await checkpoint();
} else if (options.resumeSessionGoalHeartbeat && shouldStartFreshTurn) {
const requestId = `recovery_${input.binding.runId}`;
if (recoveredSnapshot.goal?.status === "active") {
await applyNativeSessionGoalControl(session, { requestId, action: "resume" });
} else {
// Reconcile completed/paused/cleared state without changing it.
// An already-delivered outbox control must not be replayed as a
// new resume just because the old heartbeat is being recovered.
if (!session.goal) throw new Error("native_session_goal_unavailable");
await session.goal({ action: "get", requestId });
}
await checkpoint();
} else if (shouldStartFreshTurn) {
const modelEnvelope = buildNativeModelEnvelope(input);
const dispositionOnlyRecovery = Boolean(
recovered &&
@ -2175,6 +2470,14 @@ export async function executeNativeSession(
operation: async (signal) => {
const snapshot = await session.snapshot({ signal });
signal.throwIfAborted();
// A settled goal remains resumable after this controller exits. A
// merely idle warm runner is owned only by the in-memory supervisor;
// master correctly refuses that authority after a server restart.
// Suspend at this quiescent boundary, including active-goal rollover
// and clear, before returning the heartbeat result to the host. Clear
// deliberately leaves no goal snapshot, but its session still needs
// a durable handoff before the next run can create a new goal.
goalCheckpointRequiresSuspension ||= snapshot.goal != null;
const completedSnapshot = {
...snapshot,
semanticResult: durableExecutionResult.result,
@ -2230,7 +2533,7 @@ export async function executeNativeSession(
return { ...durableExecutionResult, ...enrichment };
} finally {
const shouldClose =
!options.keepSessionOpen || !executionSucceeded || sessionQuarantined;
!options.keepSessionOpen || !executionSucceeded || sessionQuarantined || goalCheckpointRequiresSuspension;
if (shouldClose && options.requireSessionCloseBeforeReturn) {
if (!failedCleanupDeferred) {
closeSession(

View File

@ -236,6 +236,87 @@ export const capabilitiesSchema = {
"additionalProperties": true
} as const;
export const capabilitiesV2Schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json",
"title": "PRP v2 negotiated capabilities",
"type": "object",
"required": [
"schema",
"sessionReusePolicy",
"driver",
"steer",
"interrupt",
"resume",
"runtimeRequests",
"structuredResult",
"typedEvents",
"sessionGoals"
],
"properties": {
"schema": {
"const": "paperclip.prp.capabilities.v2"
},
"sessionReusePolicy": {
"enum": [
"new_per_run",
"reuse_per_issue",
"reuse_per_workspace"
]
},
"driver": {
"type": "object",
"required": [
"kind",
"version"
],
"properties": {
"kind": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 80
}
},
"additionalProperties": true
},
"steer": {
"type": "boolean"
},
"interrupt": {
"type": "boolean"
},
"resume": {
"type": "boolean"
},
"runtimeRequests": {
"type": "boolean"
},
"structuredResult": {
"type": "boolean"
},
"typedEvents": {
"type": "boolean"
},
"sessionGoals": {
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"
},
"unsupported": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": true
} as const;
export const commandSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v1/command.schema.json",
@ -330,6 +411,147 @@ export const commandSchema = {
"additionalProperties": true
} as const;
export const commandV2Schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/command.schema.json",
"title": "PRP v2 runner command",
"type": "object",
"required": [
"schema",
"commandId",
"controllerSeq",
"type",
"issuedAt",
"payload"
],
"properties": {
"schema": {
"const": "paperclip.prp.command.v2"
},
"commandId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"controllerSeq": {
"type": "integer",
"minimum": 1
},
"type": {
"enum": [
"run.prepare",
"run.attach",
"session.open",
"turn.start",
"turn.steer",
"turn.interrupt",
"turn.stop",
"request.resolve",
"interaction.receipt",
"semantic_tool.result",
"session.snapshot",
"session.close",
"session.budget.increase",
"session.destroy",
"run.cancel",
"runner.drain",
"runner.suspend",
"runner.shutdown",
"session.goal.get",
"session.goal.set",
"session.goal.clear"
]
},
"issuedAt": {
"type": "string",
"format": "date-time"
},
"deadlineAt": {
"type": "string",
"format": "date-time"
},
"precondition": {
"type": "object",
"properties": {
"runnerState": {
"type": "array",
"items": {
"type": "string"
}
},
"runState": {
"type": "array",
"items": {
"type": "string"
}
},
"sessionState": {
"type": "array",
"items": {
"type": "string"
}
},
"activeTurnId": {
"type": [
"string",
"null"
]
}
},
"additionalProperties": true
},
"payload": {
"type": "object",
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "session.goal.set"
}
}
},
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"objective": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"status": {
"enum": [
"active",
"paused"
]
},
"tokenBudget": {
"type": [
"integer",
"null"
],
"minimum": 1
}
},
"additionalProperties": true
}
}
}
}
],
"additionalProperties": true
} as const;
export const providerDescriptorSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
@ -3903,6 +4125,435 @@ export const eventSchema = {
"additionalProperties": true
} as const;
export const eventV2Schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
"title": "PRP v2 native event",
"type": "object",
"required": [
"schema",
"sourceEventId",
"sourceSeq",
"sourceInstanceId",
"sourceKind",
"runId",
"eventType",
"schemaVersion",
"priority",
"emittedAt",
"payload"
],
"properties": {
"schema": {
"const": "paperclip.prp.event.v2"
},
"sourceEventId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"sourceSeq": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"sourceInstanceId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"sourceKind": {
"enum": [
"runner",
"control_plane"
]
},
"runId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"normalizedSessionId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"turnId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"itemId": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"eventType": {
"enum": [
"runner.connected",
"runner.reconnected",
"runner.reconciled",
"runner.disconnected",
"runner.draining",
"runner.suspending",
"runner.suspended",
"runner.stopped",
"runner.diagnostic",
"runtime.phase.changed",
"sandbox.metric",
"workspace.ready",
"workspace.change.updated",
"workspace.diff.recorded",
"workspace.file.referenced",
"harness.starting",
"harness.ready",
"harness.exited",
"harness.diagnostic",
"plan.updated",
"tool.execution.started",
"tool.execution.progressed",
"tool.execution.completed",
"research.started",
"research.progressed",
"research.completed",
"delegation.started",
"delegation.updated",
"delegation.completed",
"model.route.changed",
"model.verification.updated",
"context.compacted",
"artifact.viewed",
"artifact.generated",
"review.mode.changed",
"hook.started",
"hook.completed",
"memory.citation.referenced",
"safety.review.started",
"safety.review.completed",
"terminal.input.sent",
"wait.started",
"wait.completed",
"provider.notice.recorded",
"session.starting",
"session.started",
"session.resuming",
"session.resumed",
"session.reconciled",
"session.updated",
"session.closed",
"session.failed",
"session.capabilities.updated",
"session.goal.snapshot",
"session.goal.updated",
"session.goal.cleared",
"turn.submitted",
"turn.accepted",
"turn.started",
"turn.completed",
"turn.failed",
"turn.interrupted",
"turn.cancelled",
"item.started",
"item.delta",
"item.completed",
"item.failed",
"usage.reported",
"semantic_tool.input",
"semantic_tool.result",
"mcp_app.discovered",
"mcp_app.resource.resolved",
"mcp_app.initializing",
"mcp_app.ready",
"mcp_app.tool_input",
"mcp_app.tool_result",
"mcp_app.action.requested",
"mcp_app.action.resolved",
"mcp_app.host_context.changed",
"mcp_app.failed",
"mcp_app.teardown",
"runtime_request.created",
"runtime_request.resolved",
"runtime_request.expired",
"runtime_request.cancelled",
"interaction.request.proposed",
"interaction.request.materialized",
"interaction.request.rejected",
"interaction.response.progressed",
"interaction.response.resolved",
"interaction.response.delivered",
"run.attached",
"run.detached",
"run.result.proposed",
"run.result.accepted",
"run.result.rejected",
"attention.request.proposed",
"attention.request.routed",
"attention.request.resolved",
"attention.request.expired",
"attention.request.superseded",
"work.assessment.recorded",
"issue.status.decision.recorded",
"issue.status.decision.applied",
"issue.status.decision.rejected",
"issue.status.decision.superseded",
"run.terminal"
]
},
"schemaVersion": {
"const": 2
},
"priority": {
"enum": [
0,
1,
2
]
},
"emittedAt": {
"type": "string",
"format": "date-time"
},
"observedAt": {
"type": "string",
"format": "date-time"
},
"payload": {
"type": "object",
"additionalProperties": true
},
"debug": {
"type": "object",
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": {
"eventType": {
"enum": [
"session.goal.snapshot",
"session.goal.updated"
]
}
}
},
"then": {
"properties": {
"payload": {
"type": "object",
"required": [
"goal"
],
"properties": {
"goal": {
"oneOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"
},
{
"type": "null"
}
]
}
},
"additionalProperties": true
}
}
}
},
{
"if": {
"properties": {
"eventType": {
"const": "session.capabilities.updated"
}
}
},
"then": {
"properties": {
"payload": {
"type": "object",
"required": [
"sessionGoals"
],
"properties": {
"sessionGoals": {
"$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"
}
},
"additionalProperties": true
}
}
}
}
],
"additionalProperties": true
} as const;
export const sessionGoalSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json",
"title": "PRP v2 session goal capability and snapshot",
"$defs": {
"capability": {
"type": "object",
"required": [
"availability",
"actions",
"autonomousUpdates",
"persistentAcrossResume",
"maxObjectiveChars",
"tokenBudgetControl",
"usageReporting"
],
"properties": {
"availability": {
"enum": [
"available",
"unsupported",
"policy_disabled"
]
},
"actions": {
"type": "array",
"items": {
"enum": [
"set",
"pause",
"resume",
"clear"
]
},
"uniqueItems": true
},
"autonomousUpdates": {
"type": "boolean"
},
"persistentAcrossResume": {
"type": "boolean"
},
"maxObjectiveChars": {
"type": "integer",
"minimum": 1,
"maximum": 4000
},
"tokenBudgetControl": {
"type": "boolean"
},
"usageReporting": {
"type": "boolean"
},
"reasonCode": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 1000
}
},
"additionalProperties": true
},
"snapshot": {
"type": "object",
"required": [
"objective",
"status",
"tokenBudget",
"tokensUsed",
"elapsedSeconds",
"iterations",
"lastReason",
"createdAt",
"updatedAt",
"completedAt",
"workingNow"
],
"properties": {
"objective": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"status": {
"enum": [
"active",
"paused",
"blocked",
"limited",
"usage_limited",
"budget_limited",
"complete"
]
},
"tokenBudget": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"tokensUsed": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"elapsedSeconds": {
"type": [
"number",
"null"
],
"minimum": 0
},
"iterations": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"lastReason": {
"type": [
"string",
"null"
],
"maxLength": 4000
},
"createdAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"updatedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"completedAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"workingNow": {
"type": "boolean"
}
},
"additionalProperties": true
}
}
} as const;
export const fixtureSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json",
@ -3928,7 +4579,10 @@ export const fixtureSchema = {
"const": 1
},
"protocolVersion": {
"const": 1
"enum": [
1,
2
]
},
"name": {
"type": "string",
@ -3944,19 +4598,40 @@ export const fixtureSchema = {
"$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json"
},
"capabilities": {
"$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"
"oneOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"
}
]
},
"commands": {
"type": "array",
"items": {
"$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json"
"oneOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/command.schema.json"
}
]
}
},
"events": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json"
"oneOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json"
},
{
"$ref": "https://paperclip.dev/schemas/prp/v2/event.schema.json"
}
]
}
},
"requests": {
@ -3975,7 +4650,9 @@ export const fixtureSchema = {
export const prpSchemaBundle = {
"identity": identitySchema,
"capabilities": capabilitiesSchema,
"capabilities-v2": capabilitiesV2Schema,
"command": commandSchema,
"command-v2": commandV2Schema,
"provider-descriptor": providerDescriptorSchema,
"provider-event": providerEventSchema,
"workspace-diff": workspaceDiffSchema,
@ -3990,5 +4667,7 @@ export const prpSchemaBundle = {
"request": requestSchema,
"result": resultSchema,
"event": eventSchema,
"event-v2": eventV2Schema,
"session-goal": sessionGoalSchema,
"fixture": fixtureSchema,
} as const;

File diff suppressed because one or more lines are too long

View File

@ -281,7 +281,7 @@ describe("PRP v1 JSON Schema contract", () => {
it("fails closed on unsupported nested required schema versions", async () => {
const fixture = await readFixture();
const events = fixture.events as Array<Record<string, unknown>>;
events[0]!.schemaVersion = 2;
events[0]!.schemaVersion = 3;
expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
ok: false,
issues: [
@ -293,6 +293,19 @@ describe("PRP v1 JSON Schema contract", () => {
});
});
it("rejects unsafe event source sequences", async () => {
const fixture = await readFixture();
const events = fixture.events as Array<Record<string, unknown>>;
events[0]!.sourceSeq = Number.MAX_SAFE_INTEGER + 1;
const result = parsePrpFixtureText(JSON.stringify(fixture));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues.some((issue) =>
issue.code === "schema_validation" && issue.path === "/events/0/sourceSeq"
)).toBe(true);
}
});
it("requires the declared result to match the replayed result event", async () => {
const fixture = await readFixture();
const result = fixture.result as Record<string, unknown>;
@ -352,7 +365,9 @@ describe("PRP v1 JSON Schema contract", () => {
{ min: 1, max: PRP_PROTOCOL_VERSION },
{ min: 1, max: 2 },
),
).toBe(1);
expect(negotiateProtocolVersion({ min: 2, max: 3 }, { min: 1, max: 1 })).toBeNull();
).toBe(2);
expect(
negotiateProtocolVersion({ min: 2, max: 3 }, { min: 1, max: 1 }),
).toBeNull();
});
});

View File

@ -3,25 +3,31 @@ import type { FromSchema } from "json-schema-to-ts";
import {
capabilitiesSchema,
capabilitiesV2Schema,
commandSchema,
commandV2Schema,
eventSchema,
eventV2Schema,
identitySchema,
questionSetSchema,
requestSchema,
resultSchema,
semanticToolSchema,
sessionGoalSchema,
stopReasonSchema,
terminalSchema,
} from "./generated/schema-bundle.js";
import {
eventValidator as standaloneEventValidator,
eventValidator as standaloneEventV1Validator,
eventV2Validator as standaloneEventV2Validator,
fixtureValidator as standaloneFixtureValidator,
resultValidator as standaloneResultValidator,
} from "./generated/standalone-validators.js";
import { normalizeLegacyPrpStructuredRunResult } from "./result-normalization.js";
export const PRP_PROTOCOL_NAME = "paperclip.runner";
export const PRP_PROTOCOL_VERSION = 1;
export const PRP_PROTOCOL_MIN_VERSION = 1;
export const PRP_PROTOCOL_VERSION = 2;
export const PRP_FIXTURE_SCHEMA = "paperclip.prp.fixture.v1";
type TerminalReferences = [typeof stopReasonSchema];
@ -31,9 +37,24 @@ type EventReferences = [
typeof terminalSchema,
typeof resultSchema,
];
type EventV2References = [typeof sessionGoalSchema];
type CapabilitiesV2References = [typeof sessionGoalSchema];
export type PrpIdentity = FromSchema<typeof identitySchema>;
export type PrpCapabilities = FromSchema<typeof capabilitiesSchema>;
export type PrpCommand = FromSchema<typeof commandSchema>;
export type PrpCapabilitiesV2 = FromSchema<
typeof capabilitiesV2Schema,
{ references: CapabilitiesV2References }
>;
type PrpCommandV1 = FromSchema<typeof commandSchema>;
type PrpCommandV2 = FromSchema<typeof commandV2Schema>;
export interface PrpCommand {
schema: PrpCommandV1["schema"] | PrpCommandV2["schema"];
commandId: string;
controllerSeq: number;
type: PrpCommandV1["type"] | PrpCommandV2["type"];
issuedAt: string;
payload: Record<string, unknown>;
}
export type PrpSemanticToolEnvelope = FromSchema<typeof semanticToolSchema>;
export type PrpStopReason = FromSchema<typeof stopReasonSchema>;
export type PrpTerminalState = FromSchema<
@ -43,19 +64,37 @@ export type PrpTerminalState = FromSchema<
type RequestReferences = [typeof questionSetSchema];
export type PrpRequest = FromSchema<typeof requestSchema, { references: RequestReferences }>;
export type PrpStructuredRunResult = FromSchema<typeof resultSchema>;
export type PrpEvent = FromSchema<
typeof eventSchema,
{ references: EventReferences }
>;
type PrpEventV1 = FromSchema<typeof eventSchema, { references: EventReferences }>;
type PrpEventV2 = FromSchema<typeof eventV2Schema, { references: EventV2References }>;
export interface PrpEvent {
schema: PrpEventV1["schema"] | PrpEventV2["schema"];
sourceEventId: string;
sourceSeq: number;
sourceInstanceId: string;
sourceKind: PrpEventV1["sourceKind"] | PrpEventV2["sourceKind"];
runId: string;
normalizedSessionId: string;
turnId?: string;
itemId?: string;
eventType: PrpEventV1["eventType"] | PrpEventV2["eventType"];
schemaVersion: 1 | 2;
priority: 0 | 1 | 2;
emittedAt: string;
observedAt?: string;
source?: string;
type?: never;
payload: Record<string, unknown>;
debug?: Record<string, unknown>;
}
/** Runtime-validated composition of the JSON-Schema-derived contract types. */
export interface PrpFixture {
schema: typeof PRP_FIXTURE_SCHEMA;
fixtureVersion: 1;
protocolVersion: typeof PRP_PROTOCOL_VERSION;
protocolVersion: 1 | 2;
name: string;
description: string;
identity: PrpIdentity;
capabilities: PrpCapabilities;
capabilities: PrpCapabilities | PrpCapabilitiesV2;
commands: PrpCommand[];
events: PrpEvent[];
requests?: PrpRequest[];
@ -88,7 +127,8 @@ export interface ProtocolVersionRange {
// Keeping compilation out of the runtime lets strict CSP deployments retain
// `script-src 'self'` without AJV attempting dynamic JavaScript evaluation.
const fixtureValidator = standaloneFixtureValidator as ValidateFunction<PrpFixture>;
const eventValidator = standaloneEventValidator as ValidateFunction<PrpEvent>;
const eventV1Validator = standaloneEventV1Validator as ValidateFunction<PrpEvent>;
const eventV2Validator = standaloneEventV2Validator as ValidateFunction<PrpEvent>;
const resultValidator = standaloneResultValidator as ValidateFunction<PrpStructuredRunResult>;
function asRecord(value: unknown): Record<string, unknown> | null {
@ -118,10 +158,7 @@ function versionIssues(value: unknown): ProtocolValidationIssue[] {
}
const issues: ProtocolValidationIssue[] = [];
for (const [field, supported] of [
["fixtureVersion", 1],
["protocolVersion", PRP_PROTOCOL_VERSION],
] as const) {
for (const [field, supported] of [["fixtureVersion", 1]] as const) {
const actual = fixture[field];
if (typeof actual === "number" && actual !== supported) {
issues.push({
@ -131,16 +168,27 @@ function versionIssues(value: unknown): ProtocolValidationIssue[] {
});
}
}
const protocolVersion = fixture.protocolVersion;
if (
typeof protocolVersion === "number" &&
(protocolVersion < PRP_PROTOCOL_MIN_VERSION || protocolVersion > PRP_PROTOCOL_VERSION)
) {
issues.push({
code: "unsupported_required_version",
path: "/protocolVersion",
message: `protocolVersion ${protocolVersion} is unsupported; this implementation supports ${PRP_PROTOCOL_MIN_VERSION}-${PRP_PROTOCOL_VERSION}`,
});
}
if (Array.isArray(fixture.events)) {
fixture.events.forEach((entry, index) => {
const event = asRecord(entry);
const actual = event?.schemaVersion;
if (typeof actual === "number" && actual !== 1) {
if (typeof actual === "number" && actual !== 1 && actual !== 2) {
issues.push({
code: "unsupported_required_version",
path: `/events/${index}/schemaVersion`,
message: `event schemaVersion ${actual} is unsupported; this implementation requires 1`,
message: `event schemaVersion ${actual} is unsupported; this implementation supports 1-2`,
});
}
const payload = asRecord(event?.payload);
@ -420,7 +468,7 @@ export type EventValidationResult =
export function validatePrpEvent(value: unknown): EventValidationResult {
const record = asRecord(value);
const schemaVersion = record?.schemaVersion;
if (typeof schemaVersion === "number" && schemaVersion !== 1) {
if (typeof schemaVersion === "number" && schemaVersion !== 1 && schemaVersion !== 2) {
return {
ok: false,
event: null,
@ -428,11 +476,12 @@ export function validatePrpEvent(value: unknown): EventValidationResult {
{
code: "unsupported_required_version",
path: "/schemaVersion",
message: `event schemaVersion ${schemaVersion} is unsupported; this implementation requires 1`,
message: `event schemaVersion ${schemaVersion} is unsupported; this implementation supports 1-2`,
},
],
};
}
const eventValidator = schemaVersion === 2 ? eventV2Validator : eventV1Validator;
if (!eventValidator(value)) {
return {
ok: false,

View File

@ -1,5 +1,6 @@
import type {
PrpCapabilities,
PrpCapabilitiesV2,
PrpEvent,
PrpFixture,
PrpIdentity,
@ -47,7 +48,7 @@ export interface SessionSnapshot {
schema: "paperclip.prp.session-snapshot.v1";
fixtureName: string;
identity: PrpIdentity;
capabilities: PrpCapabilities;
capabilities: PrpCapabilities | PrpCapabilitiesV2;
runPhase: string;
sessionState: "not_started" | "running" | "closed" | "failed";
turnState:
@ -322,7 +323,7 @@ export function createSessionSnapshot(fixture: PrpFixture): SessionSnapshot {
export function createSessionSnapshotFromMetadata(input: {
fixtureName: string;
identity: PrpIdentity;
capabilities: PrpCapabilities;
capabilities: PrpCapabilities | PrpCapabilitiesV2;
}): SessionSnapshot {
return {
schema: "paperclip.prp.session-snapshot.v1",

View File

@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import test from "node:test";
const runnerPackage = JSON.parse(
@ -71,13 +74,18 @@ test("the runner pins every qualified ACPX production dependency", () => {
);
});
test("the patched Codex ACP command digest stays aligned across launch boundaries", () => {
test("the patched Codex ACP executable digest stays aligned across launch boundaries", async () => {
const profileMatch =
/agent: "codex"[\s\S]*?commandDigest:\s*"(sha256:[a-f0-9]{64})"/.exec(
qualifiedProfiles,
);
assert.ok(profileMatch, "qualified Codex ACPX profile digest");
const digest = profileMatch[1];
const packagePath = createRequire(import.meta.url).resolve("@agentclientprotocol/codex-acp/package.json");
const installed = JSON.parse(await readFile(packagePath, "utf8"));
const executable = await readFile(resolve(dirname(packagePath), installed.bin["codex-acp"]));
assert.equal(digest, `sha256:${createHash("sha256").update(executable).digest("hex")}`,
"the identity binds installed executable bytes, not the patch file");
assert.match(runnerdAcpxBackend, new RegExp(`"codex"[\\s\\S]*?${digest}`));
assert.match(

View File

@ -27,7 +27,7 @@ async function fixture(relativePath) {
test("all schema IDs are unique and all external references resolve", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
assert.equal(schemas.length, 21);
assert.equal(schemas.length, 25);
assert.doesNotThrow(() => compileProtocolValidators(schemas));
});
@ -59,15 +59,15 @@ test("unknown required versions and schemas fail closed", async () => {
const unsupported = await fixture("replay/unsupported-required-version.json");
assert.throws(
() => assertReplayFixtureCompatibility(unsupported),
/unsupported_required_version: protocolVersion=2; supported=1/,
/unsupported_required_version: protocolVersion=3; supported=1-2/,
);
const eventVersion = structuredClone(await fixture("replay/happy-path.json"));
eventVersion.events[0].schemaVersion = 2;
eventVersion.events[0].schemaVersion = 3;
assert.throws(() => assertReplayFixtureCompatibility(eventVersion), /unsupported_required_version/);
const commandSchema = structuredClone(await fixture("replay/happy-path.json"));
commandSchema.commands[0].schema = "paperclip.prp.command.v2";
commandSchema.commands[0].schema = "paperclip.prp.command.v3";
assert.throws(() => assertReplayFixtureCompatibility(commandSchema), /unsupported_required_schema/);
});

View File

@ -943,6 +943,7 @@ export const LIVE_EVENT_TYPES = [
"heartbeat.run.progress",
"heartbeat.run.event",
"heartbeat.run.log",
"agent.session.goal.changed",
"agent.status",
"activity.logged",
"external_object.updated",

View File

@ -1,4 +1,23 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
RUNNER_GOAL_MAX_OBJECTIVE_CHARS,
runnerGoalAvailabilitySchema,
runnerGoalCapabilityActionSchema,
runnerGoalStatusSchema,
runnerGoalActionSchema,
runnerGoalPendingActionSchema,
runnerGoalActionRequestSchema,
type RunnerGoalAvailability,
type RunnerGoalCapabilityAction,
type RunnerGoalStatus,
type RunnerGoalAction,
type RunnerGoalPendingAction,
type RunnerGoalCapability,
type RunnerGoalSnapshot,
type RunnerGoalProjection,
type RunnerGoalActionRequest,
type RunnerGoalActionAccepted,
} from "./runner-goal.js";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
export {
CONNECTION_INTENT_AGENT_GUIDANCE,

View File

@ -0,0 +1,135 @@
import { z } from "zod";
export const RUNNER_GOAL_MAX_OBJECTIVE_CHARS = 4_000;
export const runnerGoalAvailabilitySchema = z.enum([
"available",
"unsupported",
"policy_disabled",
]);
export type RunnerGoalAvailability = z.infer<typeof runnerGoalAvailabilitySchema>;
export const runnerGoalCapabilityActionSchema = z.enum(["set", "pause", "resume", "clear"]);
export type RunnerGoalCapabilityAction = z.infer<typeof runnerGoalCapabilityActionSchema>;
export const runnerGoalStatusSchema = z.enum([
"active",
"paused",
"blocked",
"limited",
"usage_limited",
"budget_limited",
"complete",
]);
export type RunnerGoalStatus = z.infer<typeof runnerGoalStatusSchema>;
export const runnerGoalActionSchema = z.enum([
"create",
"edit",
"replace",
"pause",
"resume",
"clear",
]);
export type RunnerGoalAction = z.infer<typeof runnerGoalActionSchema>;
export const runnerGoalPendingActionSchema = z.enum([
"starting",
"editing",
"replacing",
"pausing",
"resuming",
"clearing",
"continuing",
]);
export type RunnerGoalPendingAction = z.infer<typeof runnerGoalPendingActionSchema>;
export interface RunnerGoalCapability {
availability: RunnerGoalAvailability;
verified?: boolean;
actions: RunnerGoalCapabilityAction[];
autonomousUpdates: boolean;
persistentAcrossResume: boolean;
maxObjectiveChars: number;
tokenBudgetControl: boolean;
usageReporting: boolean;
reasonCode?: string | null;
reason?: string | null;
}
export interface RunnerGoalSnapshot {
objective: string;
status: RunnerGoalStatus;
tokenBudget: number | null;
tokensUsed: number;
elapsedSeconds: number;
iterations: number;
lastReason: string | null;
createdAt: string | null;
updatedAt: string | null;
completedAt: string | null;
workingNow: boolean;
}
export interface RunnerGoalProjection {
issueId: string;
agentId: string | null;
adapterType: string | null;
sessionId: string | null;
capability: RunnerGoalCapability;
goal: RunnerGoalSnapshot | null;
workingNow: boolean;
activeRunId: string | null;
pendingAction: RunnerGoalPendingAction | null;
revision: number;
observedAt: string | null;
}
const objectiveSchema = z
.string()
.trim()
.min(1)
.max(RUNNER_GOAL_MAX_OBJECTIVE_CHARS);
export const runnerGoalActionRequestSchema = z
.object({
requestId: z.string().trim().min(1).max(160),
agentId: z.string().uuid(),
expectedRevision: z.number().int().nonnegative(),
action: runnerGoalActionSchema,
objective: objectiveSchema.optional(),
tokenBudget: z.number().int().positive().nullable().optional(),
confirmReplace: z.boolean().optional(),
})
.superRefine((value, context) => {
const objectiveAction = value.action === "create" || value.action === "edit" || value.action === "replace";
if (objectiveAction && value.objective === undefined) {
context.addIssue({
code: "custom",
path: ["objective"],
message: `${value.action} requires a nonblank objective`,
});
}
if (!objectiveAction && (value.objective !== undefined || value.tokenBudget !== undefined)) {
context.addIssue({
code: "custom",
path: [value.objective !== undefined ? "objective" : "tokenBudget"],
message: `${value.action} does not accept an objective or token budget`,
});
}
if (value.action === "replace" && value.confirmReplace !== true) {
context.addIssue({
code: "custom",
path: ["confirmReplace"],
message: "replace requires explicit confirmation",
});
}
});
export type RunnerGoalActionRequest = z.infer<typeof runnerGoalActionRequestSchema>;
export interface RunnerGoalActionAccepted {
requestId: string;
status: "accepted" | "pending" | "completed" | "failed";
projection: RunnerGoalProjection;
}

View File

@ -607,6 +607,8 @@ export const updateIssueSchema = objectWithoutDefaults(
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
/** Assignment-only handoff; the following structured goal action owns the wake. */
deferWakeForGoal: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(),
});

View File

@ -109,3 +109,13 @@ diff --git a/dist/index.js b/dist/index.js
forceReload: true
});
}
@@ -30595,6 +30614,9 @@
updatedGoal
);
}
+ if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) {
+ await this.publishCurrentGoal(sessionState, sessionGeneration, true);
+ }
} else if (methodRequest.params.action === "pause") {
const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused"));
if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) {

View File

@ -1,8 +1,36 @@
diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts
index 5e2113a..b7b5151 100644
--- a/dist/client-CxNllqui.d.ts
+++ b/dist/client-CxNllqui.d.ts
@@ -135,6 +135,7 @@ declare class AcpClient {
private throwPromptPermissionFailureIfPresent;
setSessionMode(sessionId: string, modeId: string): Promise<void>;
setSessionConfigOption(sessionId: string, configId: string, value: string): Promise<SetSessionConfigOptionResponse>;
+ requestExtension(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
setSessionModel(sessionId: string, modelId: string, controlOverride?: ModelControlOverride): Promise<SetSessionConfigOptionResponse | undefined>;
private setSessionModelThroughConfig;
private setSessionModelThroughLegacyMethod;
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766f46ce3db 100644
--- a/dist/live-checkpoint-BSIrfgVo.js
+++ b/dist/live-checkpoint-BSIrfgVo.js
@@ -1661,7 +1661,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) {
last_agent_disconnect_reason: canonical.lastAgentDisconnectReason,
protocol_version: canonical.protocolVersion,
agent_capabilities: canonical.agentCapabilities,
+ agent_goal_capability: canonical.agentGoalCapability,
title: canonical.title,
messages: canonical.messages,
updated_at: canonical.updated_at,
@@ -1541,6 +1542,7 @@ function parseSessionRecord(raw) {
lastAgentDisconnectReason: optionals.lastAgentDisconnectReason,
protocolVersion: typeof record.protocol_version === "number" ? record.protocol_version : void 0,
agentCapabilities: asRecord$4(record.agent_capabilities),
+ agentGoalCapability: asRecord$4(record.agent_goal_capability),
title: conversation.title,
messages: conversation.messages,
updated_at: conversation.updated_at,
@@ -1661,7 +1663,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
"RedactedThinking",
"ToolUse"
]);
@ -114,6 +142,13 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
});
child.once("close", (exitCode, signal) => {
this.recordAgentExit("process_close", exitCode, signal);
@@ -6518,4 +6547,4 @@ var LiveSessionCheckpoint = class {
//#endregion
export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt };
-//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map
\ No newline at end of file
+//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index e8102acb03c4c38830ad5ec22f356125eb0423b7..fcb1a1906f587b33ad818389c035f90362b0617e 100644
--- a/dist/runtime.d.ts
@ -267,6 +302,96 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..50029e881c07a7228ddd978bb03d0406
const active = owner.activeTurn;
if (!active) return;
const { task, turn } = active;
@@ -1008,6 +1062,8 @@ var AcpRuntimeManager = class {
record.closedAt = void 0;
record.protocolVersion = owner.client.initializeResult?.protocolVersion;
record.agentCapabilities = owner.client.initializeResult?.agentCapabilities;
+ record.agentGoalCapability = persistedGoalCapability(owner.client.initializeResult?._meta?.goal);
+ this.options.onAgentInitialize?.(owner.client.initializeResult);
applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot());
}
async finishBufferedOwnerControl(owner, record) {
@@ -1086,6 +1142,11 @@ var AcpRuntimeManager = class {
record.closed = false;
record.closedAt = void 0;
this.closingActiveRecords.delete(record.acpxRecordId);
+ this.options.onAgentInitialize?.({
+ protocolVersion: record.protocolVersion,
+ agentCapabilities: record.agentCapabilities,
+ _meta: record.agentGoalCapability ? { goal: restoredGoalCapability(record.agentGoalCapability) } : void 0
+ });
await this.options.sessionStore.save(record);
return record;
}
@@ -1149,6 +1210,8 @@ var AcpRuntimeManager = class {
this.closingActiveRecords.delete(record.acpxRecordId);
record.protocolVersion = client.initializeResult?.protocolVersion;
record.agentCapabilities = client.initializeResult?.agentCapabilities;
+ record.agentGoalCapability = persistedGoalCapability(client.initializeResult?._meta?.goal);
+ this.options.onAgentInitialize?.(client.initializeResult);
applyConfigOptionsToRecord(record, session.sessionResult);
const modelApplication = await applyRequestedModelIfAdvertised({
client,
@@ -1469,6 +1532,10 @@ var AcpRuntimeManager = class {
setSessionConfigOption: async (configId, value) => {
return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response;
},
+ requestExtension: async (method, params) => {
+ await this.waitForRuntimeControlSession(task, turn);
+ return await turn.client.requestExtension(method, params);
+ },
setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value)
};
}
@@ -1575,6 +1642,8 @@ var AcpRuntimeManager = class {
reconcileAgentSessionId(turn.record, turn.record.agentSessionId);
turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion;
turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities;
+ turn.record.agentGoalCapability = persistedGoalCapability(turn.client.initializeResult?._meta?.goal);
+ this.options.onAgentInitialize?.(turn.client.initializeResult);
turn.record.acpx = turn.acpxState;
applyConversation(turn.record, turn.conversation);
applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot());
@@ -1707,6 +1776,17 @@ var AcpRuntimeManager = class {
});
await this.options.sessionStore.save(result.record);
}
+ async requestExtension(input) {
+ const recordId = input.handle.acpxRecordId ?? input.handle.sessionKey;
+ return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => {
+ const record = await this.requireRecord(recordId);
+ const controller = this.activeControllers.get(record.acpxRecordId);
+ if (controller) return await controller.requestExtension(input.method, input.params);
+ return (await this.withRuntimeControlSession(record, input.sessionMode ?? "persistent", async ({ client }) => {
+ return await client.requestExtension(input.method, input.params);
+ })).value;
+ });
+ }
async cancel(handle) {
await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt();
}
@@ -2119,6 +2199,14 @@ var AcpxRuntime = class {
const { handle, state } = this.resolveManagerHandle(input.handle);
await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode);
}
+ async requestExtension(input) {
+ const { handle, state } = this.resolveManagerHandle(input.handle);
+ return await (await this.getManager()).requestExtension({
+ ...input,
+ handle,
+ sessionMode: input.sessionMode ?? state.mode
+ });
+ }
async cancel(input) {
const { handle } = this.resolveManagerHandle(input.handle);
await (await this.getManager()).cancel(handle);
@@ -2178,4 +2266,4 @@ function createRuntimeStore(options) {
//#endregion
export { ACPX_BACKEND_ID, AcpRuntimeError, AcpxRuntime, DEFAULT_AGENT_NAME, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, REQUESTED_MODEL_UNSUPPORTED_REASONS, RequestedModelUnsupportedError, createAcpRuntime, createAgentRegistry, createFileSessionStore, createRuntimeStore, decodeAcpxRuntimeHandleState, encodeAcpxRuntimeHandleState, isAcpRuntimeError, isRequestedModelUnsupportedError };
-//# sourceMappingURL=runtime.js.map
\ No newline at end of file
+//# sourceMappingURL=runtime.js.map
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
index c3da1645235bbea22de3f8484149051cd7dca56b..ad1f2f6c6a7f477e83dc0061e4168c00c23382da 100644
--- a/dist/session-options-DwRDODlr.d.ts
@ -302,3 +427,18 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..ad1f2f6c6a7f477e83dc0061e4168c00
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
@@ -263,6 +275,7 @@ type SessionRecord = {
lastAgentDisconnectReason?: string;
protocolVersion?: number;
agentCapabilities?: AgentCapabilities;
+ agentGoalCapability?: Record<string, unknown>;
title?: string | null;
messages: SessionMessage[];
updated_at: string;
@@ -295,4 +308,4 @@ type SessionAgentOptions = {
};
//#endregion
export { SessionRecord as _, AcpElicitationHandler as a, AcpElicitationResponse as c, AuthPolicy as d, McpServer$1 as f, PermissionStats as g, PermissionPolicy as h, AcpElicitationContext as i, AcpPermissionDecision as l, PermissionMode as m, SystemPromptOption as n, AcpElicitationMode as o, NonInteractivePermissionPolicy as p, AcpClientOptions as r, AcpElicitationRequest as s, SessionAgentOptions as t, AcpPermissionRequest as u, PromptInput as v };
-//# sourceMappingURL=session-options-DwRDODlr.d.ts.map
\ No newline at end of file
+//# sourceMappingURL=session-options-DwRDODlr.d.ts.map

View File

@ -275,13 +275,17 @@ function runVitest(args, label) {
console.log(`\n[test:run] ${label}`);
invocationIndex += 1;
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
// Canonical roots keep security fixtures valid on macOS, where /tmp is a symlink.
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)));
// Production workspace/security checks reject symlink aliases. In particular
// /tmp is /private/tmp on macOS, so fixture roots must use the canonical path.
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, "pv-")));
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
const env = {
...process.env,
NODE_ENV: "test",
PAPERCLIP_HOME: path.join(testRoot, "h"),
// Config discovery otherwise prefers the checkout's .paperclip/config.json
// over PAPERCLIP_HOME, importing preview scheduling policy into unit tests.
PAPERCLIP_CONFIG: path.join(testRoot, "h", "config.json"),
PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`,
TMPDIR: path.join(testRoot, "t"),
};

View File

@ -207,6 +207,38 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(await fs.readFile(path.join(next.source, "SKILL.md"), "utf8")).toContain("New local instructions");
});
it("observes supporting-only local file saves across runtime preparations and service restarts", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Local supporting files", issuePrefix: `T${companyId.slice(0, 6)}` });
const skill = await svc.createLocalSkill(companyId, { name: "Local references", slug: "local-references" });
const source = skill.sourceLocator!;
await fs.writeFile(path.join(source, "reference.md"), "Original supporting file");
await db.update(companySkills).set({
fileInventory: [...skill.fileInventory, { path: "reference.md", kind: "reference" }],
}).where(eq(companySkills.id, skill.id));
const prepare = async () => (await companySkillService(db).listRuntimeSkillEntries(companyId))
.find((entry) => entry.key === skill.key)!;
const first = await prepare();
expect(first.sourceStatus).toBe("available");
expect(await fs.readFile(path.join(first.source, "reference.md"), "utf8")).toBe("Original supporting file");
await svc.updateFile(companyId, skill.id, "reference.md", "Edited supporting file");
expect((await svc.getById(companyId, skill.id))?.markdown).toBe(skill.markdown);
const next = await prepare();
expect(next.sourceStatus).toBe("available");
expect(await fs.readFile(path.join(next.source, "reference.md"), "utf8")).toBe("Edited supporting file");
await fs.writeFile(path.join(source, "reference.md"), "Direct filesystem edit");
const onDisk = await prepare();
expect(await fs.readFile(path.join(onDisk.source, "reference.md"), "utf8")).toBe("Direct filesystem edit");
// Mutable local sources never enter the immutable revision-cache fast path.
expect(first.source).toBe(source);
expect(next.source).toBe(source);
expect(onDisk.source).toBe(source);
await fs.unlink(path.join(source, "SKILL.md"));
expect(await prepare()).toBeUndefined();
});
it("lists skills without exposing markdown content", async () => {
const companyId = randomUUID();
const skillId = randomUUID();

View File

@ -16,6 +16,7 @@ import {
import {
activityLog,
agents,
agentTaskSessions,
agentRuntimeState,
agentWakeupRequests,
authUsers,
@ -437,6 +438,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(issueRecoveryActions);
await db.delete(issueTreeHoldMembers);
await db.delete(issueTreeHolds);
await db.delete(agentTaskSessions);
await db.delete(nativeRunFinalizations);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(issueComments);
@ -8377,6 +8379,44 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
});
it("does not run generic continuation recovery for a paused unfinished session goal", async () => {
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "paperclip_runner",
taskKey: issueId,
lastRunId: runId,
goalJson: {
objective: "Wait here until the user explicitly resumes me.",
status: "paused",
},
goalStatus: "paused",
goalDesiredState: "paused",
goalRevision: 2,
});
const result = await heartbeatService(db).reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.skipped).toBeGreaterThanOrEqual(1);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
expect(wakeups).toHaveLength(1);
expect(wakeups[0]?.runId).toBe(runId);
});
it("does not continue seeded in-progress work that has no run linkage", async () => {
const companyId = randomUUID();
const agentId = randomUUID();

View File

@ -45,14 +45,18 @@ function createDbStub(...selectResponses: unknown[][]) {
};
}
function loadAppModules() {
return Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
}
async function createApp(
db: Record<string, unknown>,
actor: Record<string, unknown> = { type: "anon" },
) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const [{ accessRoutes }, { errorHandler }] = await loadAppModules();
const app = express();
app.use((req, _res, next) => {
(req as any).actor = actor;
@ -72,7 +76,7 @@ async function createApp(
}
describe("GET /invites/:token", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../storage/index.js");
vi.doUnmock("../routes/access.js");
@ -80,6 +84,9 @@ describe("GET /invites/:token", () => {
registerModuleMocks();
mockStorage.headObject.mockReset();
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
// Transform the route's large dependency graph under the setup budget,
// rather than spending the first request test's timeout on module loading.
await loadAppModules();
});
it("returns company branding in the invite summary response", async () => {

View File

@ -46,6 +46,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/access.js", () => ({
@ -76,6 +80,12 @@ function registerModuleMocks() {
routineService: () => mockRoutineService,
}));
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),

View File

@ -118,6 +118,10 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => new Map()),
getIssueSummary: vi.fn(async () => ({
@ -198,6 +202,12 @@ function registerRouteMocks() {
),
}));
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
ISSUE_LIST_DEFAULT_LIMIT: 100,
ISSUE_LIST_MAX_LIMIT: 500,

View File

@ -43,6 +43,10 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(async () => null),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@ -83,6 +87,12 @@ function registerServiceMocks() {
projectService: () => mockProjectService,
}));
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),

View File

@ -91,6 +91,10 @@ const mockExternalObjectService = vi.hoisted(() => ({
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceLimitError = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceRunContextError = vi.hoisted(() => vi.fn());
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@ -133,6 +137,12 @@ vi.mock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.mock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),

View File

@ -68,8 +68,18 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
const mockIssueApprovalService = vi.hoisted(() => ({
listApprovalsForIssue: vi.fn(async () => []),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),

View File

@ -43,6 +43,10 @@ const mockDb = vi.hoisted(() => ({
transaction: vi.fn(async (callback: (tx: { select: typeof mockDbSelect }) => Promise<unknown>) =>
callback({ select: mockDbSelect })),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@ -54,6 +58,12 @@ function registerModuleMocks() {
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),

View File

@ -32,6 +32,10 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockRunnerGoalService = vi.hoisted(() => ({
projection: vi.fn(async () => null),
act: vi.fn(),
}));
vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
deliverNativeQuestionResponse: vi.fn(async () => "not_native"),
@ -39,6 +43,12 @@ vi.mock("../services/native-runtime/native-question-bridge.js", () => ({
validateNativeQuestionResponseInput: vi.fn(),
}));
vi.mock("../services/runner-goals.js", () => ({
runnerGoalService: () => mockRunnerGoalService,
RunnerGoalActionError: class RunnerGoalActionError extends Error {},
RunnerGoalConflictError: class RunnerGoalConflictError extends Error {},
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1" })),
@ -452,6 +462,29 @@ describe("issue update comment wakeups", () => {
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("defers the assignment wake when a structured goal owns the next run", async () => {
const existing = makeIssue({ assigneeAgentId: null, assigneeUserId: null, status: "todo" });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" }));
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({
assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, deferWakeForGoal: true,
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not allow goal wake deferral to suppress an unrelated status change", async () => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" });
mockIssueService.getById.mockResolvedValue(existing);
const res = await request(await createApp()).patch(`/api/issues/${existing.id}`).send({
assigneeAgentId: ASSIGNEE_AGENT_ID, status: "in_progress", deferWakeForGoal: true,
});
expect(res.status).toBe(400);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("wakes the assignee on comment-only issue updates", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,

View File

@ -179,6 +179,45 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
expect(row).toEqual({ checkoutRunId: runningRunId, executionRunId: runningRunId });
});
it("does not terminalize a session-goal control run solely because its issue is done", async () => {
const { companyId, agentId, runningRunId } = await seed();
const issueId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
resumeIntent: true,
goalControlRequestId: randomUUID(),
runnerGoalControl: { action: "clear" },
},
})
.where(eq(heartbeatRuns.id, runningRunId));
await db.insert(issues).values({
id: issueId,
companyId,
title: "Completed goal awaiting clear",
status: "done",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: runningRunId,
executionRunId: runningRunId,
executionLockedAt: new Date(),
});
const result = await heartbeatService(db).sweepStaleIssueLocks();
expect(result.terminalizedRunIds).toEqual([]);
expect(result.cleared).toBe(0);
await expect(
db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runningRunId))
.then((rows) => rows[0]?.status),
).resolves.toBe("running");
});
it("does not clear when checkoutRunId is terminal but executionRunId is still running", async () => {
const { companyId, agentId, failedRunId, runningRunId } = await seed();
const issueId = randomUUID();

View File

@ -100,6 +100,10 @@ describe("runtime skill revision cache", () => {
expect(await fs.readFile(path.join(next!, "references/a.md"), "utf8")).toBe("updated");
});
it("never fingerprints mutable local sources as immutable cached revisions", () => {
expect(runtimeSkillCacheSpec(root, { ...skill, sourceType: "local_path", sourceRef: null })).toBeNull();
});
it.each(["manifest-missing", "manifest-malformed", "changed", "deleted", "extra", "symlink"])("rejects %s without read-only repair, then rebuilds", async (corruption) => {
const spec = runtimeSkillCacheSpec(root, skill)!;
const source = (await resolveRuntimeSkillCache(spec, reader()))!;

View File

@ -59,6 +59,8 @@ const {
reapOrphanedRuns: vi.fn(async () => ({ reaped: 0, runIds: [] })),
promoteDueScheduledRetries: vi.fn(async () => ({ promoted: 0, runIds: [] })),
resumeQueuedRuns: vi.fn(async () => undefined),
recoverPendingSessionGoalActions: vi.fn(async () => ({ scanned: 0, enqueued: 0, alreadyQueued: 0, invalid: 0 })),
recoverActiveSessionGoals: vi.fn(async () => ({ scanned: 0, enqueued: 0 })),
reconcileStrandedAssignedIssues: vi.fn(async () => ({
assignmentDispatched: 0,
dispatchRequeued: 0,

View File

@ -1460,6 +1460,23 @@ async function startServerWithDatabaseTeardown(
const promotion = await heartbeat.promoteDueScheduledRetries();
await heartbeat.resumeQueuedRuns();
const recoveredGoalActions = await heartbeat.recoverPendingSessionGoalActions();
if (
recoveredGoalActions.enqueued > 0 ||
recoveredGoalActions.invalid > 0
) {
logger.warn(
recoveredGoalActions,
"startup session-goal action outbox recovery reconciled pending controls",
);
}
const recoveredGoals = await heartbeat.recoverActiveSessionGoals();
if (recoveredGoals.enqueued > 0) {
logger.warn(
recoveredGoals,
"startup session-goal recovery resumed durable agent goals",
);
}
const reconciled = await heartbeat.reconcileStrandedAssignedIssues();
if (
promotion.promoted > 0 ||

Some files were not shown because too many files have changed in this diff Show More