Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
68 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
89e0428
🤖 refactor: rework workflow terminal wakes as level-triggered reconci…
ibetitsmike Aug 30, 2026
b1f89dd
🤖 test: rework workflow terminal wake tests for level-triggered recon…
ibetitsmike Aug 30, 2026
ecff903
🤖 refactor: clean up leftovers from the wake reconciliation redesign
ibetitsmike Aug 30, 2026
0ad9f9b
🤖 fix: never let a damaged settlement marker abort the attention sweep
ibetitsmike Aug 30, 2026
7fe39e5
🤖 fix: retire kernel workflow references before a full clear commits
ibetitsmike Aug 30, 2026
75d5b64
🤖 fix: re-poke terminal attention drain when suppression empties the …
ibetitsmike Aug 30, 2026
e152d68
Merge origin/main (seam role-interface refactor) into mike/workflow-k…
ibetitsmike Aug 30, 2026
b2bf4c3
🤖 fix: harden terminal settlement markers and emptying truncations
ibetitsmike Aug 31, 2026
ced2265
Merge origin/main (router thinning) into mike/workflow-kernel-termina…
ibetitsmike Aug 31, 2026
631e082
🤖 fix: revalidate emptiness and currentness at their commit points
ibetitsmike Aug 31, 2026
ef5474d
🤖 fix: retire kernel workflow references on every conversation-mutati…
ibetitsmike Aug 31, 2026
9c8e0e0
🤖 fix: classify truncation scope and retry stalled wake groups
ibetitsmike Aug 31, 2026
6a60bdf
🤖 fix: harden terminal wake liveness, dedupe, and downgrade compat
ibetitsmike Aug 31, 2026
165667c
🤖 fix: align wake policy, admission, and downgrade reset with termina…
ibetitsmike Aug 31, 2026
6b2de11
Merge remote-tracking branch 'origin/main' into mike/workflow-kernel-…
ibetitsmike Aug 31, 2026
f46c7a2
🤖 fix: revalidate wake groups after identity resolution and back off …
ibetitsmike Aug 31, 2026
d2de0cb
🤖 fix: honor stable settlement markers on upgrade and reconcile wakes…
ibetitsmike Aug 31, 2026
d6c4f30
🤖 fix: revalidate run generation after settlement writes to protect r…
ibetitsmike Aug 31, 2026
2ac7f66
🤖 fix: revalidate workflow currentness before the busy fallback send
ibetitsmike Aug 31, 2026
355ac54
🤖 fix: refuse truncation when the scope preflight is unreadable
ibetitsmike Aug 31, 2026
32ad570
🤖 fix: refresh the stable settlement marker with generation evidence
ibetitsmike Aug 31, 2026
0d1c089
🤖 fix: refuse a full-clear-classified truncation that would leave rows
ibetitsmike Aug 31, 2026
940ea64
🤖 merge: main (Config god-class split)
ibetitsmike Aug 31, 2026
70b22c3
🤖 fix: revalidate workflow wakes at dispatch and harden truncation/un…
ibetitsmike Aug 31, 2026
a15a79d
🤖 fix: order dispatch revalidation reads and serialize workflow settl…
ibetitsmike Aug 31, 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 });
}
});
});
59 changes: 54 additions & 5 deletions src/node/services/agentWorkflowRunReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ import { MutexMap } from "@/node/utils/concurrency/mutexMap";
export interface AgentWorkflowRunReference {
runId: string;
createdAtMs: number;
/**
* Message ID of the newest invocation-decision row (manual user/reset supersession, consumed
* terminal result for this run, or direct invocation part) at record time; null when history
* had none. Row identity, not wall clock: currentness compares this against the row the
* history walk stops at, so a backward clock correction cannot reorder the comparison.
* Absent on legacy entries, which fail safe to not-current.
*/
afterBoundaryMessageId?: string | null;
}

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 +42,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 +55,34 @@ 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;
}
const boundaryRaw = record.afterBoundaryMessageId;
const afterBoundaryMessageId =
typeof boundaryRaw === "string" && boundaryRaw.length > 0
? boundaryRaw
: boundaryRaw === null
? null
: undefined;
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
// 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. The chosen record is kept wholesale, including its boundary snapshot.
const existing = parsedByRunId.get(record.runId);
if (existing == null || record.createdAtMs > existing.createdAtMs) {
parsedByRunId.set(record.runId, {
runId: record.runId,
createdAtMs: record.createdAtMs,
...(afterBoundaryMessageId !== undefined ? { afterBoundaryMessageId } : {}),
});
}
}
return parsed;
return Array.from(parsedByRunId.values());
}

export async function readAgentWorkflowRunReferences(
Expand All @@ -64,18 +103,28 @@ export async function recordAgentWorkflowRunReference(input: {
workspaceSessionDir: string;
runId: string;
createdAtMs?: number;
afterBoundaryMessageId?: string | null;
}): Promise<void> {
assert(input.runId.length > 0, "agent workflow reference requires runId");
const filePath = referencesPath(input.workspaceSessionDir);

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 (listAgentReferencedWorkflowRunIds).
createdAtMs: previous ? Math.max(previous.createdAtMs, createdAtMs) : createdAtMs,
Comment thread
ibetitsmike marked this conversation as resolved.
// The new record event defines currentness provenance wholesale; a caller without
// boundary knowledge produces a legacy-style entry that fails safe to not-current.
...(input.afterBoundaryMessageId !== undefined
? { afterBoundaryMessageId: input.afterBoundaryMessageId }
: {}),
});

await fs.mkdir(path.dirname(filePath), { recursive: true });
Expand Down
53 changes: 53 additions & 0 deletions src/node/services/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@ function createWorkspaceServiceMocks(
mock((_workspaceId: string, _message: WorkspaceChatMessage) => undefined);
const isWorkflowInvocationCurrent =
overrides?.isWorkflowInvocationCurrent ?? mock(() => Promise.resolve(true));
// Derived from the boolean mock so tests that override isWorkflowInvocationCurrent keep
// steering the drain's three-state check.
const getWorkflowInvocationCurrentness = mock(
async (workspaceId: string, runId: string) =>
((await isWorkflowInvocationCurrent(workspaceId, runId)) === true
? "current"
: "not_current") as "current" | "not_current" | "indeterminate"
);
const countQueuedAgentPeerMessages = overrides?.countQueuedAgentPeerMessages ?? mock(() => 0);
// Granted by default (no live user activity): interrupt_active tests exercise the
// interruption/archive flow; the hold's own refusal logic lives in workspaceService.test.ts.
Expand Down Expand Up @@ -728,6 +736,7 @@ function createWorkspaceServiceMocks(
isExperimentEnabled,
emitChatEvent,
isWorkflowInvocationCurrent,
getWorkflowInvocationCurrentness,
countQueuedAgentPeerMessages,
} as unknown as WorkspaceService,
create,
Expand Down Expand Up @@ -6239,6 +6248,50 @@ describe("TaskService", () => {
expect(await terminalAttentionStore.listPending(parentId)).toHaveLength(0);
});

test("terminal workflow wake-up defers when history is unreadable", async () => {
const config = await createTestConfig(rootDir);
const { parentId } = await saveLocalParentWorkspace(config, rootDir);
const runId = "wfr_terminal_defer";
const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) });
await runStore.createRun({
id: runId,
workspaceId: parentId,
workflow: {
name: "research",
description: "Research workflow",
scope: "built-in",
executable: true,
},
source: "export default function workflow() { return { reportMarkdown: 'done' }; }\n",
args: {},
attentionPolicy: "notify_on_terminal",
now: "2026-06-19T00:00:00.000Z",
});
await runStore.appendStatus(runId, "running", "2026-06-19T00:00:01.000Z");
await runStore.appendStatus(runId, "completed", "2026-06-19T00:00:03.000Z");

const terminalAttentionStore = new TerminalAttentionStore(config);
const sendMessage = mock(
(..._args: unknown[]): Promise<Result<void>> => Promise.resolve(Ok(undefined))
);
const { workspaceService } = createWorkspaceServiceMocks({ sendMessage });
// History unreadable at drain time: currentness is indeterminate, so the notification must
// stay pending for a later drain instead of being tombstoned as superseded.
(workspaceService as unknown as Record<string, unknown>).getWorkflowInvocationCurrentness =
mock(() => Promise.resolve("indeterminate"));
const { taskService } = createTaskServiceHarness(config, { workspaceService });

await taskService.enqueueWorkflowRunTerminalAttention({
ownerWorkspaceId: parentId,
runId,
status: "completed",
});
await flushTerminalAttentionDrains(taskService);

expect(sendMessage).not.toHaveBeenCalled();
expect(await terminalAttentionStore.listPending(parentId)).toHaveLength(1);
});

test("initialize replays and clears persisted pending task guidance", async () => {
const config = await createTestConfig(rootDir);
const projectPath = path.join(rootDir, "repo");
Expand Down
Loading
Loading