Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
40 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
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
32 changes: 32 additions & 0 deletions src/node/services/agentWorkflowRunReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,36 @@ describe("agent workflow run references", () => {
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 });
}
});
});
14 changes: 11 additions & 3 deletions src/node/services/agentWorkflowRunReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function parseReferences(value: unknown): AgentWorkflowRunReference[] {
}

const parsed: AgentWorkflowRunReference[] = [];
const now = Date.now();
for (const reference of references) {
if (reference == null || typeof reference !== "object") {
continue;
Expand All @@ -41,7 +42,10 @@ function parseReferences(value: unknown): AgentWorkflowRunReference[] {
if (typeof record.createdAtMs !== "number" || !Number.isFinite(record.createdAtMs)) {
continue;
}
parsed.push({ runId: record.runId, createdAtMs: record.createdAtMs });
// Self-heal implausible future timestamps (clock correction, corruption): a future-dated
// reference would otherwise outrank every later user/reset boundary in supersession
// comparisons until wall time catches up.
parsed.push({ runId: record.runId, createdAtMs: Math.min(record.createdAtMs, now) });
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}
return parsed;
}
Expand Down Expand Up @@ -71,11 +75,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
107 changes: 107 additions & 0 deletions src/node/services/workspaceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ import type { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/typ
import { createMuxMessage } from "@/common/types/message";
import { buildStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments";
import {
WORKFLOW_RESULT_METADATA_TYPE,
WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE,
WORKFLOW_TRIGGER_DISPLAY_METADATA_TYPE,
} from "@/common/utils/workflowRunMessages";
import { recordAgentWorkflowRunReference } from "@/node/services/agentWorkflowRunReferences";
import { getPlanFilePath } from "@/common/utils/planStorage";
import * as todoStorageModule from "@/node/services/todos/todoStorage";
import * as runtimeFactory from "@/node/runtime/runtimeFactory";
Expand Down Expand Up @@ -5924,6 +5926,111 @@ describe("WorkspaceService workflow invocation events", () => {
}
});

test("counts a kernel-launched run recorded in the sidecar as the current invocation", async () => {
const { config, historyService, cleanup } = await createTestHistoryService();
const workspaceId = "workflow-currentness-kernel";
const runId = "wfr_currentness_kernel";
const projectPath = path.join(config.rootDir, "project");
try {
await config.addWorkspace(projectPath, {
id: workspaceId,
name: "workflow-currentness-kernel",
projectName: "project",
projectPath,
runtimeConfig: { type: "local" },
});
const workspaceService = createWorkspaceServiceForTest({
config,
historyService,
aiService: createMockAIService({
stopStream: mock(() => Promise.resolve(Ok(undefined))),
}),
extensionMetadata: new ExtensionMetadataService(
path.join(config.rootDir, "extensionMetadata.json")
),
initStateManager: {
...mockInitStateManager,
off: mock(() => undefined as unknown as InitStateManager),
} as unknown as InitStateManager,
});

// mux.workflow_run inside code_execution leaves no workflow_run tool part in history; the
// agent-workflow-runs sidecar reference is the only durable invocation evidence.
await historyService.appendToHistory(
workspaceId,
createMuxMessage("manual-user", "user", "run the audit workflow", { timestamp: 1_000 })
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("assistant-kernel-launch", "assistant", "", { timestamp: 1_100 }, [
{
type: "dynamic-tool",
toolCallId: "code-exec-1",
toolName: "code_execution",
state: "output-available",
input: { code: "return xum.workflow_run({ script_path: './workflows/demo.js' })" },
output: { success: true, result: { status: "running", runId } },
},
])
);

// The nested runId in the code_execution output alone is not invocation evidence.
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(false);

await recordAgentWorkflowRunReference({
workspaceSessionDir: config.getSessionDir(workspaceId),
runId,
createdAtMs: 1_150,
});
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(true);

// A newer manual user message supersedes the sidecar reference.
await historyService.appendToHistory(
workspaceId,
createMuxMessage("manual-user-2", "user", "never mind, answer something else", {
timestamp: 1_200,
})
);
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(false);

// A kernel workflow_resume re-records the reference after the supersession and
// re-establishes provenance (latest record wins).
await recordAgentWorkflowRunReference({
workspaceSessionDir: config.getSessionDir(workspaceId),
runId,
createdAtMs: 1_250,
});
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(true);

// Once the terminal result was delivered, the sidecar must not resurrect the invocation.
await historyService.appendToHistory(
workspaceId,
createMuxMessage("workflow-result", "user", "The workflow below has finished.", {
timestamp: 1_300,
synthetic: true,
muxMetadata: {
type: WORKFLOW_RESULT_METADATA_TYPE,
rawCommand: "workflow_run ./workflows/demo.js",
runId,
},
})
);
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(false);

// A kernel background resume issued after the delivered result re-records the reference,
// so the retried run's next terminal wake must count as current again.
await recordAgentWorkflowRunReference({
workspaceSessionDir: config.getSessionDir(workspaceId),
runId,
createdAtMs: 1_350,
});
expect(await workspaceService.isWorkflowInvocationCurrent(workspaceId, runId)).toBe(true);
workspaceService.disposeSession(workspaceId);
} finally {
await cleanup();
}
});

test.each(["workflow_run", "workflow_resume"] as const)(
"treats terminal %s output as a consumed workflow result",
async (toolName) => {
Expand Down
Loading
Loading