Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions src/node/services/agentWorkflowRunReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,18 @@ describe("agent workflow run references", () => {
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 });
}
});
});
5 changes: 4 additions & 1 deletion src/node/services/agentWorkflowRunReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export async function recordAgentWorkflowRunReference(input: {
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
98 changes: 98 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,102 @@ 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);
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
47 changes: 32 additions & 15 deletions src/node/services/workspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout";
import { EventEmitter } from "events";
import * as path from "path";
import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock";
import { readAgentWorkflowRunReferences } from "@/node/services/agentWorkflowRunReferences";
import * as fsPromises from "fs/promises";
import assert from "@/common/utils/assert";
import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior";
Expand Down Expand Up @@ -10935,35 +10936,29 @@ export class WorkspaceService extends EventEmitter {
assert(workspaceId.length > 0, "isWorkflowInvocationCurrent requires workspaceId");
assert(runId.length > 0, "isWorkflowInvocationCurrent requires runId");

let current = false;
let foundDecision = false;
let outcome: "invocation" | "consumed" | "superseded" | null = null;
let supersededAtMs: number | null = null;
const historyResult = await this.historyService.iterateFullHistory(
workspaceId,
"backward",
(messages) => {
for (const message of messages) {
if (isManualUserSupersessionMessage(message)) {
current = false;
foundDecision = true;
return false;
}
if (isResetBoundaryMessage(message)) {
current = false;
foundDecision = true;
if (isManualUserSupersessionMessage(message) || isResetBoundaryMessage(message)) {
outcome = "superseded";
const timestamp = message.metadata?.timestamp;
supersededAtMs = typeof timestamp === "number" ? timestamp : null;
return false;
}
if (
isWorkflowResultContinuationMessage(message, runId) ||
isTerminalWorkflowTaskAwaitResultMessage(message, runId) ||
isTerminalWorkflowToolResultMessage(message, runId)
) {
current = false;
foundDecision = true;
outcome = "consumed";
return false;
}
if (isWorkflowInvocationMessage(message, runId)) {
current = true;
foundDecision = true;
outcome = "invocation";
return false;
}
}
Expand All @@ -10979,7 +10974,29 @@ export class WorkspaceService extends EventEmitter {
return false;
}

return foundDecision && current;
if (outcome === "invocation") {
return true;
}
if (outcome === "consumed") {
return false;
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}

// Kernel-launched runs (mux.workflow_run / mux.workflow_resume inside code_execution) leave
// no recognizable invocation part in history, so the backward walk above stops at the prior
// real user message and would wrongly treat the run as superseded, silently dropping its
// notify_on_terminal wake. Their durable provenance is the agent-workflow-runs sidecar: a
// reference recorded after the latest supersession boundary counts as the current
// invocation. A boundary without a durable timestamp fails safe to superseded, mirroring
// TaskService.listAgentReferencedWorkflowRunIds.
if (outcome === "superseded" && supersededAtMs === null) {
return false;
}
const references = await readAgentWorkflowRunReferences(this.config.getSessionDir(workspaceId));
const reference = references.find((candidate) => candidate.runId === runId);
Comment thread
ibetitsmike marked this conversation as resolved.
if (reference == null) {
return false;
}
return supersededAtMs === null || reference.createdAtMs > supersededAtMs;
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}

/**
Expand Down
Loading