Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9665489
🤖 fix: deliver terminal wakes for kernel-launched background workflow…
ibetitsmike Aug 27, 2026
f41cbfb
🤖 fix: persist workflow_resume terminal consumption for kernel-nested…
ibetitsmike Aug 27, 2026
a19c5a3
🤖 fix: let newer sidecar records outrank consumed results; clamp futu…
ibetitsmike Aug 27, 2026
30a3437
🤖 fix: reject persisted future-dated sidecar references instead of cl…
ibetitsmike Aug 27, 2026
89263dd
🤖 fix: fail safe after full history clear; dedupe sidecar references …
ibetitsmike Aug 27, 2026
59ce349
🤖 fix: tolerate bounded backward-clock skew in sidecar reference parsing
ibetitsmike Aug 27, 2026
76e6e80
🤖 fix: decide kernel workflow currentness by boundary-row identity, n…
ibetitsmike Aug 27, 2026
046286a
🤖 fix: never persist a boundary snapshot from an unreadable history; …
ibetitsmike Aug 27, 2026
e5de1dd
🤖 fix: migrate pre-snapshot sidecar references through a wall-clock f…
ibetitsmike Aug 27, 2026
60bb82d
🤖 fix: deliver kernel workflow wakes launched from a decision-free hi…
ibetitsmike Aug 27, 2026
a73a354
🤖 fix: harden kernel workflow wake delivery against sidecar faults
ibetitsmike Aug 27, 2026
983d3c5
🤖 fix: retire kernel workflow run references on a full history clear
ibetitsmike Aug 27, 2026
56c789b
🤖 fix: close round-10 wake-delivery gaps: retirement ordering, record…
ibetitsmike Aug 27, 2026
521fba2
🤖 fix: close round-11 gaps: record retry, indeterminate recovery, cle…
ibetitsmike Aug 27, 2026
3eb19a5
🤖 fix: close round-12 lifecycle gaps for kernel workflow wake provenance
ibetitsmike Aug 27, 2026
62dd290
🤖 fix: round-13 sidecar lifecycle hardening: boundary repair, removal…
ibetitsmike Aug 27, 2026
6866c5b
🤖 fix: round-14 provenance integrity: supersede-older retries, genera…
ibetitsmike Aug 27, 2026
e14574d
🤖 fix: restore the caller tool policy on kernel workflow wakes
ibetitsmike Aug 27, 2026
95ca943
🤖 refactor: strip rounds 11-14 retry/repair machinery; keep identity-…
ibetitsmike Aug 28, 2026
d35491c
🤖 fix: resolve the wake's agent identity with an unbounded history walk
ibetitsmike Aug 28, 2026
0792396
🤖 fix: sanitize persisted wake restrictions before restoring them
ibetitsmike Aug 28, 2026
9280614
🤖 fix: persist kernel workflow provenance before the runner can reach…
ibetitsmike Aug 28, 2026
42a596b
🤖 fix: preserve the caller tool policy through on-send compaction fol…
ibetitsmike Aug 28, 2026
c59964f
🤖 fix: record resume provenance only after the dispatch restarts the run
ibetitsmike Aug 28, 2026
2befd06
🤖 fix: restore the strict-agent pin on terminal wakes
ibetitsmike Aug 28, 2026
36bb23c
🤖 fix: forward the object-form strict-agent pin on terminal wakes
ibetitsmike Aug 28, 2026
cf0323c
🤖 fix: stop compaction recovery from clobbering preserved follow-up f…
ibetitsmike Aug 28, 2026
9f57ada
🤖 fix: bind workflow terminal wakes to the initiating agent
ibetitsmike Aug 28, 2026
125aae5
🤖 fix: schema-validate persisted initiating agent IDs
ibetitsmike Aug 28, 2026
e1b1954
🤖 fix: split coalesced workflow wakes by initiating agent
ibetitsmike Aug 28, 2026
cb31308
🤖 fix: isolate wake identity groups and honor synthetic launch pins
ibetitsmike Aug 28, 2026
8e1bcd6
🤖 fix: harden wake provenance writes, reads, and pin pairing
ibetitsmike Aug 28, 2026
d53ac1a
🤖 fix: defer boundaryless workflow references instead of wall-clock o…
ibetitsmike Aug 28, 2026
cd1b200
🤖 fix: split wakes by launch pin and repair downgrade-stripped proven…
ibetitsmike Aug 28, 2026
578d72b
🤖 fix: gate crash-resume provenance repair on supersession-free evidence
ibetitsmike Aug 28, 2026
56171b7
🤖 fix: make crash-resume boundary repair a compare-and-set under the …
ibetitsmike Aug 28, 2026
6776f48
🤖 fix: defer identity-less wakes and wire terminal attention into cra…
ibetitsmike Aug 28, 2026
ce9085a
🤖 fix: retain and retry failed workflow terminal attention enqueues
ibetitsmike Aug 28, 2026
7edfe81
🤖 fix: harden workflow wake recovery (resume reset, repair retry, res…
ibetitsmike Aug 28, 2026
8b30f29
🤖 fix: complete failed workflow notification resets on the next termi…
ibetitsmike Aug 28, 2026
895315c
Merge remote-tracking branch 'origin/main' into mike/workflow-kernel-…
ibetitsmike Aug 30, 2026
40dc6e0
🤖 fix: guard workflow terminal wakes against removed owners and stale…
ibetitsmike Aug 30, 2026
9331135
🤖 test: make the crash-repair retry test robust to slow runners
ibetitsmike Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/node/services/agentWorkflowRunReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,118 @@ describe("agent workflow run references", () => {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});

test("collapses persisted duplicate entries to the newest sane timestamp", async () => {
const workspaceSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-workflow-runs-"));
try {
// Corrupted files can carry duplicates in either order; order-sensitive consumers must
// never observe a stale duplicate ahead of a legitimate re-record.
await fs.writeFile(
path.join(workspaceSessionDir, "agent-workflow-runs.json"),
JSON.stringify({
references: [
{ runId: "wfr_dup", createdAtMs: 1_000 },
{ runId: "wfr_dup", createdAtMs: 2_000 },
{ runId: "wfr_dup_reversed", createdAtMs: 2_000 },
{ runId: "wfr_dup_reversed", createdAtMs: 1_000 },
],
})
);

const references = await readAgentWorkflowRunReferences(workspaceSessionDir);
expect(references).toHaveLength(2);
expect(references).toContainEqual({ runId: "wfr_dup", createdAtMs: 2_000 });
expect(references).toContainEqual({ runId: "wfr_dup_reversed", createdAtMs: 2_000 });
} finally {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});

test("drops persisted future-dated references and repairs them on the next record", async () => {
const workspaceSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-workflow-runs-"));
try {
const futureMs = Date.now() + 86_400_000;
await fs.writeFile(
path.join(workspaceSessionDir, "agent-workflow-runs.json"),
JSON.stringify({
references: [
{ runId: "wfr_corrupt_future", createdAtMs: futureMs },
{ runId: "wfr_sane", createdAtMs: 1_000 },
],
})
);

// A per-read clamp would re-evaluate to "now" on every read and outrank every later
// user/reset boundary; the corrupted entry must be dropped instead.
expect(await readAgentWorkflowRunReferences(workspaceSessionDir)).toEqual([
{ runId: "wfr_sane", createdAtMs: 1_000 },
]);

await recordAgentWorkflowRunReference({
workspaceSessionDir,
runId: "wfr_corrupt_future",
createdAtMs: 2_000,
});
const repaired = await readAgentWorkflowRunReferences(workspaceSessionDir);
expect(repaired).toContainEqual({ runId: "wfr_corrupt_future", createdAtMs: 2_000 });
const raw = await fs.readFile(
path.join(workspaceSessionDir, "agent-workflow-runs.json"),
"utf-8"
);
expect(raw).not.toContain(String(futureMs));
} finally {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});

test("keeps references within the backward-clock skew tolerance", async () => {
const workspaceSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-workflow-runs-"));
try {
// A backward clock correction makes a legitimately recorded reference look slightly
// future-dated; dropping it would strand the run's terminal wake.
const slightlyFutureMs = Date.now() + 5 * 60_000;
await fs.writeFile(
path.join(workspaceSessionDir, "agent-workflow-runs.json"),
JSON.stringify({ references: [{ runId: "wfr_clock_skew", createdAtMs: slightlyFutureMs }] })
);

expect(await readAgentWorkflowRunReferences(workspaceSessionDir)).toEqual([
{ runId: "wfr_clock_skew", createdAtMs: slightlyFutureMs },
]);
} finally {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});

test("clamps future-dated createdAtMs to the current time", async () => {
const workspaceSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-workflow-runs-"));
try {
const runId = "wfr_future";
await recordAgentWorkflowRunReference({
workspaceSessionDir,
runId,
createdAtMs: Date.now() + 86_400_000,
});

const references = await readAgentWorkflowRunReferences(workspaceSessionDir);
expect(references).toHaveLength(1);
expect(references[0]?.createdAtMs).toBeLessThanOrEqual(Date.now());
} finally {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});

test("keeps the newest createdAtMs across re-records", async () => {
const workspaceSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-workflow-runs-"));
try {
const runId = "wfr_re_recorded";
await recordAgentWorkflowRunReference({ workspaceSessionDir, runId, createdAtMs: 2_000 });
await recordAgentWorkflowRunReference({ workspaceSessionDir, runId, createdAtMs: 1_000 });

const references = await readAgentWorkflowRunReferences(workspaceSessionDir);
expect(references).toEqual([{ runId, createdAtMs: 2_000 }]);
} finally {
await fs.rm(workspaceSessionDir, { recursive: true, force: true });
}
});
});
34 changes: 29 additions & 5 deletions src/node/services/agentWorkflowRunReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export interface AgentWorkflowRunReference {

const AGENT_WORKFLOW_RUN_REFERENCES_FILE = "agent-workflow-runs.json";

// Backward clock corrections (e.g. an NTP step after booting with a fast clock) can make a
// legitimately recorded reference look future-dated. Tolerate that bounded skew so the run's
// terminal wake is not dropped; only implausibly future values are treated as corruption.
const MAX_FUTURE_SKEW_MS = 60 * 60_000;

const referenceFileLocks = new MutexMap<string>();

function referencesPath(workspaceSessionDir: string): string {
Expand All @@ -29,7 +34,8 @@ function parseReferences(value: unknown): AgentWorkflowRunReference[] {
return [];
}

const parsed: AgentWorkflowRunReference[] = [];
const parsedByRunId = new Map<string, AgentWorkflowRunReference>();
const now = Date.now();
for (const reference of references) {
if (reference == null || typeof reference !== "object") {
continue;
Expand All @@ -41,9 +47,23 @@ function parseReferences(value: unknown): AgentWorkflowRunReference[] {
if (typeof record.createdAtMs !== "number" || !Number.isFinite(record.createdAtMs)) {
continue;
}
parsed.push({ runId: record.runId, createdAtMs: record.createdAtMs });
// Reject implausibly future-dated references (corruption) instead of clamping at read
// time: a per-read clamp re-evaluates to "now" on every read, so the entry would outrank
// every later user/reset boundary until wall time catches up. Values within
// MAX_FUTURE_SKEW_MS are kept as-is (backward clock correction, not corruption). Rejected
// entries are replaced with a sane timestamp by the next legitimate record.
if (record.createdAtMs > now + MAX_FUTURE_SKEW_MS) {
Comment thread
ibetitsmike marked this conversation as resolved.
continue;
}
// Collapse corrupted duplicate entries to the newest sane timestamp so order-sensitive
// consumers cannot pick a stale duplicate and declare a legitimately re-recorded run
// superseded.
const existing = parsedByRunId.get(record.runId);
if (existing == null || record.createdAtMs > existing.createdAtMs) {
parsedByRunId.set(record.runId, { runId: record.runId, createdAtMs: record.createdAtMs });
}
}
return parsed;
return Array.from(parsedByRunId.values());
}

export async function readAgentWorkflowRunReferences(
Expand Down Expand Up @@ -71,11 +91,15 @@ export async function recordAgentWorkflowRunReference(input: {
await referenceFileLocks.withLock(filePath, async () => {
const existing = await readAgentWorkflowRunReferences(input.workspaceSessionDir);
const byRunId = new Map(existing.map((reference) => [reference.runId, reference]));
const createdAtMs = input.createdAtMs ?? Date.now();
// Clamp like parseReferences: never persist a future-dated timestamp.
const createdAtMs = Math.min(input.createdAtMs ?? Date.now(), Date.now());
const previous = byRunId.get(input.runId);
byRunId.set(input.runId, {
runId: input.runId,
createdAtMs: previous ? Math.min(previous.createdAtMs, createdAtMs) : createdAtMs,
// Latest record wins: workflow_resume re-records the reference, and a resume issued after
// a manual user message must re-establish provenance for supersession-timestamp
// comparisons (isWorkflowInvocationCurrent, listAgentReferencedWorkflowRunIds).
createdAtMs: previous ? Math.max(previous.createdAtMs, createdAtMs) : createdAtMs,
Comment thread
ibetitsmike marked this conversation as resolved.
});

await fs.mkdir(path.dirname(filePath), { recursive: true });
Expand Down
5 changes: 5 additions & 0 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8232,6 +8232,11 @@ export class TaskService {
notification.sourceId
);
if (workflowPrompt == null) {
// Dropping a notify_on_terminal wake strands the run's owner; keep the drop diagnosable.
log.warn("Dropping superseded workflow terminal attention", {
ownerWorkspaceId,
runId: notification.sourceId,
});
await this.terminalAttentionStore.markSuperseded(ownerWorkspaceId, notification.id);
continue;
}
Expand Down
100 changes: 100 additions & 0 deletions src/node/services/tools/workflow_resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TestTempDir, createTestToolConfig } from "./testHelpers";
import { readAgentWorkflowRunReferences } from "@/node/services/agentWorkflowRunReferences";
import { WORKFLOW_CHECKPOINT_RETRY_ERROR_MESSAGE } from "@/common/utils/workflowRetryEligibility";
import type { WorkflowRunRecord } from "@/common/types/workflow";
import type { TaskService } from "@/node/services/taskService";
import type { WorkflowRunAttachedEvent } from "@/common/types/stream";

const mockToolCallOptions: ToolExecutionOptions<unknown> = {
Expand Down Expand Up @@ -229,6 +230,105 @@ describe("workflow_resume tool", () => {
});
});

test("marks terminal attention consumed when returning an already-completed run's result", async () => {
using tempDir = new TestTempDir("test-workflow-resume-consumed");
const completedRun = buildRun({
status: "completed",
events: [
{ sequence: 1, type: "status", at: "2026-05-29T00:00:00.000Z", status: "running" },
{
sequence: 2,
type: "result",
at: "2026-05-29T00:00:01.000Z",
result: { reportMarkdown: "already done" },
},
{ sequence: 3, type: "status", at: "2026-05-29T00:00:01.000Z", status: "completed" },
],
});
const workflowService = buildWorkflowService({ getRun: mock(async () => completedRun) });
const markWorkflowRunTerminalAttentionConsumed = mock(() => Promise.resolve());
const tool = createWorkflowResumeTool({
...createTestToolConfig(tempDir.path, { workspaceId: "workspace-1" }),
trusted: true,
workflowService,
taskService: { markWorkflowRunTerminalAttentionConsumed } as unknown as TaskService,
});

await tool.execute!(
{ run_id: "wfr_resume_me", run_in_background: false, mode: null },
mockToolCallOptions
);

expect(markWorkflowRunTerminalAttentionConsumed).toHaveBeenCalledWith({
ownerWorkspaceId: "workspace-1",
runId: "wfr_resume_me",
status: "completed",
});
});

test("does not mark terminal attention consumed for background dispatches", async () => {
using tempDir = new TestTempDir("test-workflow-resume-background-no-consume");
// The refresh after a background dispatch can still observe the stale pre-dispatch failed
// status; consuming it would tombstone the retried run's future terminal wake.
const workflowService = buildWorkflowService({ getRun: mock(async () => buildFailedRun()) });
const markWorkflowRunTerminalAttentionConsumed = mock(() => Promise.resolve());
const tool = createWorkflowResumeTool({
...createTestToolConfig(tempDir.path, { workspaceId: "workspace-1" }),
trusted: true,
workflowService,
taskService: { markWorkflowRunTerminalAttentionConsumed } as unknown as TaskService,
});

await tool.execute!(
{ run_id: "wfr_resume_me", run_in_background: true, mode: "retry_from_checkpoint" },
mockToolCallOptions
);

expect(markWorkflowRunTerminalAttentionConsumed).not.toHaveBeenCalled();
});

test("marks terminal attention consumed when a foreground retry finishes terminal", async () => {
using tempDir = new TestTempDir("test-workflow-resume-foreground-consume");
const failedRun = buildFailedRun();
const completedRun = buildRun({
status: "completed",
events: [
{ sequence: 1, type: "status", at: "2026-05-29T00:00:00.000Z", status: "running" },
{ sequence: 2, type: "status", at: "2026-05-29T00:00:02.000Z", status: "completed" },
],
});
let getRunCalls = 0;
const workflowService = buildWorkflowService({
getRun: mock(async () => {
getRunCalls += 1;
return getRunCalls === 1 ? failedRun : completedRun;
}),
retryRunFromCheckpoint: mock(async () => ({
runId: "wfr_resume_me",
status: "completed" as const,
result: { reportMarkdown: "retried" },
})),
});
const markWorkflowRunTerminalAttentionConsumed = mock(() => Promise.resolve());
const tool = createWorkflowResumeTool({
...createTestToolConfig(tempDir.path, { workspaceId: "workspace-1" }),
trusted: true,
workflowService,
taskService: { markWorkflowRunTerminalAttentionConsumed } as unknown as TaskService,
});

await tool.execute!(
{ run_id: "wfr_resume_me", run_in_background: false, mode: "retry_from_checkpoint" },
mockToolCallOptions
);

expect(markWorkflowRunTerminalAttentionConsumed).toHaveBeenCalledWith({
ownerWorkspaceId: "workspace-1",
runId: "wfr_resume_me",
status: "completed",
});
});

test("rejects default resume of a failed run with checkpoint retry guidance", async () => {
using tempDir = new TestTempDir("test-workflow-resume-failed");
const workflowService = buildWorkflowService({ getRun: mock(async () => buildFailedRun()) });
Expand Down
24 changes: 23 additions & 1 deletion src/node/services/tools/workflow_resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { tool } from "ai";

import { getErrorMessage } from "@/common/utils/errors";
import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools";
import type { WorkflowRunRecord } from "@/common/types/workflow";
import { isTerminalWorkflowRunStatus, type WorkflowRunRecord } from "@/common/types/workflow";
import { getWorkflowCheckpointRetryEligibility } from "@/common/utils/workflowRetryEligibility";
import { WorkflowRunRecordSchema } from "@/common/orpc/schemas";
import {
Expand Down Expand Up @@ -154,9 +154,25 @@ export const createWorkflowResumeTool: ToolFactory = (config: ToolConfiguration)
const mode: WorkflowResumeMode = args.mode ?? "resume";
const invocationStartedAtMs = Date.now();

// A kernel-nested resume (mux.workflow_resume inside code_execution) leaves no top-level
// workflow_resume part in history, so the history-walk consumption predicates cannot see
// that this turn already received the terminal result. Persist consumption durably so the
// terminal-attention drain never re-delivers it.
const markTerminalAttentionConsumed = async (terminalRun: WorkflowRunRecord) => {
if (!isTerminalWorkflowRunStatus(terminalRun.status)) {
return;
}
await config.taskService?.markWorkflowRunTerminalAttentionConsumed?.({
ownerWorkspaceId: workspaceId,
runId: terminalRun.id,
status: terminalRun.status,
});
};

// Idempotent success: the work is already done, so hand back the durable result instead
// of failing the agent's recovery loop (e.g. resuming after a crash that actually finished).
if (run.status === "completed" && mode === "resume") {
await markTerminalAttentionConsumed(run);
Comment thread
ibetitsmike marked this conversation as resolved.
return parseToolResult(
WorkflowResumeToolResultSchema,
{
Expand Down Expand Up @@ -231,6 +247,12 @@ export const createWorkflowResumeTool: ToolFactory = (config: ToolConfiguration)
const refreshedRunIsStale =
isBackgroundDispatch && refreshedRun != null && refreshedRun.status === run.status;

// Foreground only: a background dispatch can still observe the stale pre-dispatch terminal
// status, and consuming it would tombstone the retried run's future terminal wake.
if (!isBackgroundDispatch && refreshedRun != null) {
await markTerminalAttentionConsumed(refreshedRun);
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}

return parseToolResult(
WorkflowResumeToolResultSchema,
{
Expand Down
Loading
Loading