diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 19bcbf074a8..500813bfe20 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -3018,8 +3018,22 @@ identity mismatches. Native task-session publication is part of the terminal checkpoint transaction, not resource cleanup. `native-task-session-publication.ts` owns its accepted-result, owner, task-epoch and reset checks; heartbeat supplies prepared session metadata. +The goal projection also advances its source epoch when a fresh native session +holds the task's current execution lock and assignee. The predecessor must be +terminal, and its saved goal must be absent or complete. This lets the accepted +checkpoint publish the fresh session as the next continuation baseline. A +different session without current task authority, an unfinished or unreadable +goal, a reset conversation, and delayed predecessor events retain their fences. The goal-less regression in `native-session-resumption.test.ts` holds provider cleanup open and then exercises a normal follow-up and an explicit session reset. +Its fresh-session case keeps the prior task-session row, opens one new provider +session, and verifies that the following ordinary comment resumes that session. +The opt-in `native-session-epoch.live.test.ts` runs the same path with an existing +Codex login and a built native Runner. It uses warm mode, makes three model calls, +checks exact file contents and provider-thread reuse, and removes its temporary +credential copy and workspace. Run it with +`PAPERCLIP_LIVE_SESSION_EPOCH=true pnpm exec vitest run server/src/services/native-runtime/native-session-epoch.live.test.ts`. +`PAPERCLIP_LIVE_EVIDENCE_PATH` can select a JSON evidence file outside the fixture. Control-port tests also cover terminal snapshots before accepted results and result-replay contention with the status writer's coordinator-first lock order. Remote adoption tests must invoke actual external-state preparation. Inspecting diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index d76d46f64aa..cd96c833aa4 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2438,6 +2438,14 @@ configuration, and controller-owned credential or instruction resources use verified suspension and saved-history resume. This recovery can restart a provider; it does not suspend every healthy warm turn. +A fresh native session admitted for the current task may replace a terminal +predecessor's goal source epoch when the saved goal is absent or complete. +It must hold the task's execution lock and assignee. Its accepted terminal +checkpoint can then publish the new session for later comments or monitors. +An unfinished or unreadable goal retains its prior epoch. Delayed predecessor +events, stale source sequences, conversation resets and unverified outcomes +retain their existing guards. Source projection does not start provider work. + Changing configuration can retire a completed warm runner during instruction preparation, before the next native coordinator is claimed. Its maintenance checkpoints may use the next run's live preparation lease from the same diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index 3650f12b166..f68593c9b6a 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -4,6 +4,11 @@ Product map: [Recovery](../feature-map/recovery.md). Feature ID: `recovery`. +Fresh native retry session publication remains server-owned. Goal source +replacement requires the current task execution lock and assignee, a terminal +predecessor, and an absent or completed saved goal. It grants no agent API or +permission to start another executor or replay an unknown external outcome. + Native finalization deadline reconciliation records unfinished workspace preservation through the existing server-owned recovery path. It grants no additional agent API, provider-stop authority or permission to replay work. diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index 8d387551747..a57568f9f22 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -36,6 +36,10 @@ Agent access: [companion](../agent-access-map/recovery.md). Close still requires confirmed provider exit, durable event drain, and exact runner suspension; the cleanup purpose alone cannot certify termination. Hosted provider settlement remains separate acceptance. + An authorized fresh native retry publishes its own session for the next + ordinary comment or monitor. The [heartbeat regression](../../server/src/__tests__/native-session-resumption.test.ts) + covers fresh admission, saved session selection, and one subsequent resume. + Unfinished goals and unverified prior outcomes retain their recovery gates. - `source-action`: `recovery/source-action` — source-scoped recovery actions remain attached to the task; independent repair work may have its own issue and return owner. - `exhaustion`: `recovery/exhaustion` — an exhausted infrastructure attempt offers ordinary Retry. diff --git a/server/src/__tests__/native-session-resumption.test.ts b/server/src/__tests__/native-session-resumption.test.ts index 264bc8327a3..57f6d89b717 100644 --- a/server/src/__tests__/native-session-resumption.test.ts +++ b/server/src/__tests__/native-session-resumption.test.ts @@ -1534,7 +1534,7 @@ describe("goal-less native task-session publication", () => { await database?.cleanup(); }); - it.each(["retained", "reset"] as const)("publishes the completed task session before provider cleanup and resumes an ordinary comment (%s)", async mode => { + it.each(["retained", "reset", "fresh"] as const)("publishes the completed task session before provider cleanup and resumes an ordinary comment (%s)", async mode => { const home = createIsolatedTestHome(); const previousHome = process.env.PAPERCLIP_HOME; process.env.PAPERCLIP_HOME = home.home; @@ -1643,7 +1643,8 @@ describe("goal-less native task-session publication", () => { const comment = await issuesSvc.addComment(issueId, "Continue using the saved artifact.", { userId: "pointer-user" }); const second = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment", requestedByActorType: "user", requestedByActorId: "pointer-user", payload: { issueId, commentId: comment.id, mutation: "comment", reopenedFrom: "done" }, - contextSnapshot: { issueId, taskId: issueId, wakeCommentId: comment.id, reopenedFrom: "done", skipIssueComment: true } }); + contextSnapshot: { issueId, taskId: issueId, wakeCommentId: comment.id, reopenedFrom: "done", skipIssueComment: true, + ...(mode === "fresh" ? { forceFreshSession: true } : {}) } }); expect(second).not.toBeNull(); await heartbeat.drainActiveRunExecutions(); const after = await heartbeat.getRun(second!.id); @@ -1664,6 +1665,27 @@ describe("goal-less native task-session publication", () => { } expect(startTurn).toHaveBeenCalledTimes(2); expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, second!.id))).toHaveLength(1); + if (mode === "fresh") { + expect(await heartbeat.listTaskSessions(agentId)).toEqual([expect.objectContaining({ + lastRunId: second!.id, sessionParamsJson: expect.objectContaining({ sessionId: after!.nativeSessionId }), + sessionDisplayId: after!.sessionIdAfter, + })]); + await issuesSvc.update(issueId, { status: "todo", actorUserId: "pointer-user" }); + const followup = await issuesSvc.addComment(issueId, "Continue the fresh session's saved work.", { userId: "pointer-user" }); + const third = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment", + requestedByActorType: "user", requestedByActorId: "pointer-user", + payload: { issueId, commentId: followup.id, mutation: "comment", reopenedFrom: "done" }, + contextSnapshot: { issueId, taskId: issueId, wakeCommentId: followup.id, reopenedFrom: "done", skipIssueComment: true } }); + expect(third).not.toBeNull(); + await heartbeat.drainActiveRunExecutions(); + expect(await heartbeat.getRun(third!.id)).toMatchObject({ status: "succeeded", + nativeSessionId: after!.nativeSessionId, sessionIdAfter: after!.sessionIdAfter }); + expect(inputs).toHaveLength(3); + expect(openSession).toHaveBeenCalledTimes(2); + expect(recoverSession).toHaveBeenCalledOnce(); + expect(startTurn).toHaveBeenCalledTimes(3); + expect(await db.select().from(nativeRunResults).where(inArray(nativeRunResults.runId, [first!.id, second!.id, third!.id]))).toHaveLength(3); + } } finally { releaseClose(); try { diff --git a/server/src/services/native-runtime/native-session-epoch.live.test.ts b/server/src/services/native-runtime/native-session-epoch.live.test.ts new file mode 100644 index 00000000000..f04416140d7 --- /dev/null +++ b/server/src/services/native-runtime/native-session-epoch.live.test.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { agents, agentTaskSessions, companies, heartbeatRuns, issues } from "@paperclipai/db"; +import { expect, it } from "vitest"; +import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js"; +import { createIsolatedTestHome } from "../../__tests__/helpers/isolated-test-home.js"; +import { heartbeatService } from "../heartbeat.js"; +import { issueService } from "../issues.js"; +import { instanceSettingsService } from "../instance-settings.js"; +import { closeIdleWarmNativeSessionsForRestart } from "./native-session-executor.js"; + +it.skipIf(process.env.PAPERCLIP_LIVE_SESSION_EPOCH !== "true")("publishes a fresh live Codex session and resumes its saved file", async () => { + const home = createIsolatedTestHome(), root = home.home; + const lifecycleMode = process.env.PAPERCLIP_LIVE_SESSION_LIFECYCLE === "per_turn" ? "per_turn" : "warm"; + const keys = ["PAPERCLIP_HOME", "CODEX_HOME", "PAPERCLIP_API_URL", "PAPERCLIP_AGENT_JWT_SECRET", "PAPERCLIP_TELEMETRY_DISABLED"]; + const saved = Object.fromEntries(keys.map(key => [key, process.env[key]])); + let server: Awaited> | undefined; + let heartbeat: ReturnType | undefined; + try { + const codexHome = join(root, "codex"); + await mkdir(codexHome, { mode: 0o700 }); + await copyFile(join(process.env.PAPERCLIP_LIVE_CODEX_HOME ?? join(homedir(), ".codex"), "auth.json"), join(codexHome, "auth.json")); + await writeFile(join(codexHome, "config.toml"), 'model_reasoning_effort = "low"\n'); + process.env.CODEX_HOME = codexHome; + process.env.PAPERCLIP_HOME = join(root, "instance"); + process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); + process.env.PAPERCLIP_TELEMETRY_DISABLED = "1"; + server = await startRunnerApiTestServer({ deploymentMode: "local_trusted" }); + process.env.PAPERCLIP_API_URL = server.apiUrl; + const fixture = await server.fixture({ connectionScenario: "fresh" }); + const db = server.db; + await db.update(issues).set({ executionRunId: null, executionWorkspaceSettings: { mode: "shared_workspace" }, + title: "Preserve one verification file across native sessions", + description: "Follow the latest comment's single file step. The deliverable is epoch.txt in this workspace. Read the file to verify the step. Complete this step with paperclip_finish, reportedWorkDisposition done, and file evidence. Do not do later steps or repeat earlier steps.", + }).where(eq(issues.id, fixture.issueId)); + await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, fixture.runId)); + await db.update(companies).set({ defaultResponsibleUserId: fixture.userId }).where(eq(companies.id, fixture.companyId)); + await db.update(agents).set({ adapterConfig: { provider: "codex", model: "gpt-6.1-sol", modelReasoningEffort: "low", + cwd: fixture.workspace, workspaceStrategy: { type: "project_primary" }, env: { CODEX_HOME: codexHome }, timeoutSec: 180, + lifecycleMode, idleTimeoutMs: 900_000 }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 1 } }, + }).where(eq(agents.id, fixture.agentId)); + await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableIsolatedWorkspaces: true }); + heartbeat = heartbeatService(db); + const service = issueService(db), markers = [randomUUID(), randomUUID(), randomUUID()]; + const completed: Array<{ runId: string; sessionId: string; providerSessionId: string; publication: unknown }> = []; + for (const [index, marker] of markers.entries()) { + await service.update(fixture.issueId, { status: "todo", actorUserId: fixture.userId! }); + const comment = await service.addComment(fixture.issueId, + index === 0 ? `Write exactly ${marker} followed by a newline to epoch.txt. Read it and finish this step.` + : `Append exactly ${marker} followed by a newline once to epoch.txt. Preserve all existing lines. Read the result and finish this step.`, + { userId: fixture.userId! }); + const run = await heartbeat.wakeup(fixture.agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment", + requestedByActorType: "user", requestedByActorId: fixture.userId, + payload: { issueId: fixture.issueId, commentId: comment.id, mutation: "comment", reopenedFrom: "done" }, + contextSnapshot: { issueId: fixture.issueId, taskId: fixture.issueId, wakeCommentId: comment.id, skipIssueComment: true, + ...(index === 1 ? { forceFreshSession: true } : {}) }, + }); + expect(run?.id).toBeTruthy(); + await heartbeat.drainActiveRunExecutions(); + const after = await heartbeat.getRun(run!.id); + console.log(JSON.stringify({ stage: index, status: after?.status, errorCode: after?.errorCode, error: after?.error })); + expect(after?.status).toBe("succeeded"); + const [session] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.taskKey, fixture.issueId)); + expect(session.lastRunId).toBe(run!.id); + expect(session.sessionParamsJson?.sessionId).toBe(after!.nativeSessionId); + expect(await readFile(join(fixture.workspace, "epoch.txt"), "utf8")).toBe(markers.slice(0, index + 1).join("\n") + "\n"); + completed.push({ runId: run!.id, sessionId: after!.nativeSessionId!, providerSessionId: after!.sessionIdAfter!, + publication: after!.runnerProfileJson?.nativeTaskSessionPublication }); + } + console.log(JSON.stringify({ observedSessions: completed })); + expect(completed[1].sessionId).not.toBe(completed[0].sessionId); + expect(completed[1].providerSessionId).not.toBe(completed[0].providerSessionId); + expect(completed[2].sessionId).toBe(completed[1].sessionId); + expect(completed[2].providerSessionId).toBe(completed[1].providerSessionId); + const evidence = { passed: true, model: "gpt-6.1-sol", lifecycleMode, provider: "real local Codex login", platform: `${process.platform}-${process.arch}`, + turns: 3, freshSessionPublished: true, ordinaryFollowupResumedFreshSession: true, exactFileLines: 3, completed }; + if (process.env.PAPERCLIP_LIVE_EVIDENCE_PATH) await writeFile(process.env.PAPERCLIP_LIVE_EVIDENCE_PATH, JSON.stringify(evidence, null, 2) + "\n"); + console.log(JSON.stringify(evidence)); + } finally { + await heartbeat?.drainActiveRunExecutions(); + await closeIdleWarmNativeSessionsForRestart(); + await server?.close(); + for (const key of keys) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } + home.cleanup(); + } +}, 600_000); diff --git a/server/src/services/native-runtime/native-task-session-publication.test.ts b/server/src/services/native-runtime/native-task-session-publication.test.ts index 4ab4c73c059..a45926d19ad 100644 --- a/server/src/services/native-runtime/native-task-session-publication.test.ts +++ b/server/src/services/native-runtime/native-task-session-publication.test.ts @@ -12,6 +12,7 @@ import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js"; import { redactRegisteredSecretValues } from "../run-secret-redaction.js"; import { nativeSha256 } from "./canonical.js"; +import { applyRunnerGoalPrpEvent } from "../runner-goals.js"; import { PaperclipControlPlanePort, type PaperclipControlPlaneBinding } from "./paperclip-control-plane-port.js"; import { nativeTaskSessionPublicationHandled, publishNativeTaskSessionInTransaction, NATIVE_TASK_SESSION_PUBLICATION_KEY, type NativeTaskSessionPublicationContext } from "./native-task-session-publication.js"; @@ -115,6 +116,30 @@ describe("accepted native task session publication", () => { expect(repeated.filter(query => /^update /i.test(query.trim()))).toHaveLength(1); }); + it("publishes a fresh authorized retry after a different provider session owned the goal epoch", async () => { + const f = await fixture(); + const priorRunId = randomUUID(), priorSessionId = randomUUID(), priorRunnerId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: priorRunId, companyId: f.binding.companyId, + agentId: f.binding.agentId, nativeIssueId: f.binding.issueId, nativeSessionId: priorSessionId, + runnerInstanceId: priorRunnerId, runtimeMode: "native", status: "failed", + createdAt: new Date(Date.now() - 10_000), finishedAt: new Date(Date.now() - 5_000) }); + await db.update(agentTaskSessions).set({ lastRunId: priorRunId, + sessionParamsJson: { sessionId: priorSessionId }, sessionDisplayId: "prior-provider-thread", + goalSourceId: `${priorRunnerId}:${priorRunId}`, goalSourceCursor: 1_000, + }).where(eq(agentTaskSessions.taskKey, f.binding.issueId)); + + await applyRunnerGoalPrpEvent(db, { ...f.binding, adapterType: f.context.adapterType }, { + eventType: "session.goal.snapshot", sourceInstanceId: f.binding.sourceInstanceId, + sourceRunId: f.binding.runId, sourceSeq: 1, payload: { goal: null, workingNow: false }, + }); + await f.port.checkpointSession(f.snapshot); + const { run, session } = await f.read(); + expect(session).toMatchObject({ lastRunId: f.binding.runId, + sessionParamsJson: { ...f.context.sessionParamsJson, sessionId: f.binding.sessionId }, + sessionDisplayId: "provider-thread", goalSourceId: `${f.binding.sourceInstanceId}:${f.binding.runId}` }); + expect(run.runnerProfileJson?.nativeTaskSessionPublication).toMatchObject({ outcome: "published" }); + }); + it("persists a pre-acceptance terminal observation and publishes only at the existing accepted checkpoint", async () => { const f = await fixture("runner", false); // Driver event/startup checkpoints can expose a semantic terminal observation diff --git a/server/src/services/runner-goals.test.ts b/server/src/services/runner-goals.test.ts index 5b7d8481897..a254586ae81 100644 --- a/server/src/services/runner-goals.test.ts +++ b/server/src/services/runner-goals.test.ts @@ -409,6 +409,60 @@ describeEmbeddedPostgres("runner goal service", () => { expect(session).toMatchObject({ goalSourceId: `${runnerId}:${nextRunId}`, goalSourceCursor: 2 }); }); + it.each(["current", "current_complete", "not_current", "reassigned", "blocked", "prior_running", "next_finished", "foreign_runner", "missing_session", "unfinished_goal", "unreadable_goal", "conversation_reset"] as const)("fences a fresh goal source by current execution authority (%s)", async kind => { + const binding = await seed(), priorRunId = randomUUID(), nextRunId = randomUUID(); + const priorRunnerId = randomUUID(), nextRunnerId = randomUUID(); + const priorSessionId = randomUUID(), nextSessionId = randomUUID(); + const eventBinding = { ...binding, adapterType: "paperclip_runner" }; + await db.insert(heartbeatRuns).values([ + { id: priorRunId, ...binding, nativeIssueId: binding.issueId, runtimeMode: "native", status: "failed", + runnerInstanceId: priorRunnerId, nativeSessionId: priorSessionId }, + { id: nextRunId, ...binding, nativeIssueId: binding.issueId, runtimeMode: "native", status: "running", + runnerInstanceId: nextRunnerId, nativeSessionId: nextSessionId }, + ]); + await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, binding.issueId)); + const priorEvent = { eventType: "session.goal.updated", sourceInstanceId: priorRunnerId, + sourceRunId: priorRunId, sourceSeq: 20, + payload: { goal: { objective: "Retained goal", status: kind === "current_complete" ? "complete" : "active" } } }; + await applyRunnerGoalPrpEvent(db, eventBinding, priorEvent); + if (kind !== "unfinished_goal" && kind !== "current_complete") await applyRunnerGoalPrpEvent(db, eventBinding, { + ...priorEvent, eventType: "session.goal.cleared", sourceSeq: 21, payload: { goal: null }, + }); + if (kind === "not_current") await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, binding.issueId)); + if (kind === "reassigned") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, binding.issueId)); + if (kind === "blocked") await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, binding.issueId)); + if (kind === "prior_running") await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, priorRunId)); + if (kind === "next_finished") await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, nextRunId)); + if (kind === "foreign_runner") await db.update(heartbeatRuns).set({ runnerInstanceId: randomUUID() }).where(eq(heartbeatRuns.id, nextRunId)); + if (kind === "missing_session") await db.update(heartbeatRuns).set({ nativeSessionId: null }).where(eq(heartbeatRuns.id, nextRunId)); + if (kind === "unreadable_goal") await db.update(agentTaskSessions).set({ goalJson: { legacy: "unknown" } }).where(eq(agentTaskSessions.taskKey, binding.issueId)); + if (kind === "conversation_reset") await db.update(issues).set({ conversationAgentId: binding.agentId, + conversationUserId: "goal-reset-user", conversationState: "active", conversationSessionGeneration: 2, + }).where(eq(issues.id, binding.issueId)); + const nextEvent = { eventType: "session.goal.updated", sourceInstanceId: nextRunnerId, + sourceRunId: nextRunId, sourceSeq: 1, + payload: { goal: { objective: "Current fresh session", status: "active" } } }; + const changed = await applyRunnerGoalPrpEvent(db, eventBinding, nextEvent); + const [session] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.taskKey, binding.issueId)); + if (kind !== "current" && kind !== "current_complete") { + expect(changed).toBeNull(); + expect(session.goalSourceId).toBe(`${priorRunnerId}:${priorRunId}`); + expect(session.goalSourceCursor).toBe(kind === "unfinished_goal" ? 20 : 21); + return; + } + expect(changed?.goal).toMatchObject({ objective: "Current fresh session", status: "active" }); + expect(session).toMatchObject({ goalSourceId: `${nextRunnerId}:${nextRunId}`, goalSourceCursor: 1 }); + await applyRunnerGoalPrpEvent(db, eventBinding, { ...nextEvent, eventType: "session.goal.cleared", + sourceSeq: 2, payload: { goal: null } }); + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, nextRunId)); + for (const stale of [{ ...priorEvent, sourceSeq: 1_000 }, nextEvent, + { eventType: "session.goal.updated", sourceSeq: 2_000, payload: priorEvent.payload }]) { + expect(await applyRunnerGoalPrpEvent(db, eventBinding, stale)).toBeNull(); + } + expect(await runnerGoalService(db).projection(binding.companyId, binding.issueId)).toMatchObject({ goal: null }); + expect(await db.select().from(agentSessionGoalActions)).toHaveLength(0); + }); + it("keeps a missing resumed goal blocked across restored empty snapshots", async () => { const binding = await seed(); const eventBinding = { ...binding, adapterType: "paperclip_runner" }; diff --git a/server/src/services/runner-goals.ts b/server/src/services/runner-goals.ts index cc28cabbb54..69d2fdf9d38 100644 --- a/server/src/services/runner-goals.ts +++ b/server/src/services/runner-goals.ts @@ -687,10 +687,6 @@ export async function applyRunnerGoalPrpEvent( : undefined; const sameSource = !sourceId || session?.goalSourceId === sourceId; if (session?.goalSourceId && sourceId !== session.goalSourceId) { - // goalSourceId is the persisted run epoch, not just a sequence namespace. - // Only a running, authenticated successor of the same native session may - // replace it. A delayed terminal predecessor must never reclaim the epoch - // or resurrect a clear tombstone, even with a higher source sequence. const priorRunId = session.goalSourceId.split(":").at(-1); const nextRunId = event.sourceRunId; const isUuid = (value: string | undefined): value is string => @@ -701,6 +697,7 @@ export async function applyRunnerGoalPrpEvent( status: heartbeatRuns.status, nativeSessionId: heartbeatRuns.nativeSessionId, runnerInstanceId: heartbeatRuns.runnerInstanceId, + runtimeMode: heartbeatRuns.runtimeMode, }).from(heartbeatRuns).where(and( inArray(heartbeatRuns.id, [priorRunId, nextRunId]), eq(heartbeatRuns.companyId, binding.companyId), @@ -709,10 +706,15 @@ export async function applyRunnerGoalPrpEvent( )); const prior = owners.find((run) => run.id === priorRunId); const next = owners.find((run) => run.id === nextRunId); + const priorGoal = storedGoal(session); + const currentFreshSession = issue?.executionRunId === nextRunId && + issue.assigneeAgentId === binding.agentId && issue.status === "in_progress" && + next?.runtimeMode === "native" && prior?.runtimeMode === "native" && + !!prior.nativeSessionId && (session.goalJson === null || priorGoal?.status === "complete"); if ( !prior || !next || next.status !== "running" || next.runnerInstanceId !== event.sourceInstanceId || - !next.nativeSessionId || next.nativeSessionId !== prior.nativeSessionId || + !next.nativeSessionId || (next.nativeSessionId !== prior.nativeSessionId && !currentFreshSession) || (prior.id !== next.id && !["succeeded", "failed", "cancelled", "timed_out"].includes(prior.status)) ) return false; }