Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
2fa12ce
perf: scope workspace.activity.list to config-known workspaces and pr…
ThomasK33 Aug 25, 2026
404a375
review: close cross-process prune race and cover failed task-create r…
ThomasK33 Aug 25, 2026
7d895ed
review: abort prune and scoping when config load falls back to defaults
ThomasK33 Aug 25, 2026
f0f29f7
review: reject structurally invalid project lists in strict config loads
ThomasK33 Aug 26, 2026
b3f6254
review: derive prune ids from the raw config superset, not lossy norm…
ThomasK33 Aug 26, 2026
a0a743e
review: tombstone deleted extension metadata entries against late wri…
ThomasK33 Aug 26, 2026
5c31340
review: apply prune deletions to a fresh snapshot instead of rewritin…
ThomasK33 Aug 26, 2026
248cd0e
tests: clear activity snapshot without tombstoning in goal preview fa…
ThomasK33 Aug 26, 2026
ce6aa06
review: recheck tombstones inside queued mutations; only tombstone af…
ThomasK33 Aug 26, 2026
3371c61
review: suppress post-removal activity emissions, authoritative empty…
ThomasK33 Aug 26, 2026
608f663
review: verify deregistration before metadata tombstone; strict metad…
ThomasK33 Aug 26, 2026
74e0b3e
review: validate workspaces container in strict metadata loads; drop …
ThomasK33 Aug 26, 2026
703ab51
review: ENOENT-only empty metadata state, ACP activity fallback, post…
ThomasK33 Aug 26, 2026
995ea09
test: update activity-list empty-payload contract; cover rejection ke…
ThomasK33 Aug 26, 2026
901ad2d
review: route ACP fallback through log helper; revalidate list agains…
ThomasK33 Aug 26, 2026
8109191
review: quarantine deterministic metadata corruption; config-membersh…
ThomasK33 Aug 26, 2026
67c65af
review: ENOENT-only config existence semantics; propagate legacy iden…
ThomasK33 Aug 26, 2026
c2f7da9
test: accept the corrupt-config write gate as the trust CLI's loud fa…
ThomasK33 Aug 26, 2026
0c14bdc
review: pre-prune removal baseline; content-guarded quarantine restore
ThomasK33 Aug 26, 2026
9e36449
review: copy-based quarantine restore fallback; authoritative legacy …
ThomasK33 Aug 26, 2026
3813ecd
review: propagate quarantine restore failures; refresh first-bootstra…
ThomasK33 Aug 26, 2026
417d3c0
Merge origin/main into activity-list-scoping
ThomasK33 Aug 26, 2026
a5c9018
review: address round-19 findings
ThomasK33 Aug 26, 2026
bcd4656
review: model the null activity.list failure signal in the ACP sessio…
ThomasK33 Aug 26, 2026
373809e
review: address round-21 findings
ThomasK33 Aug 26, 2026
10c950f
review: close the post-recovery ENOENT TOCTOU in metadata load
ThomasK33 Aug 26, 2026
ad6e8ca
review: lenient writers complete a crash-interrupted quarantine inline
ThomasK33 Aug 26, 2026
dd32db7
review: propagate non-ENOENT sidecar probe failures
ThomasK33 Aug 26, 2026
06cd1ae
review: revalidate raw-invisible legacy ids through the authoritative…
ThomasK33 Aug 26, 2026
1f5466e
review: address round-26 findings
ThomasK33 Aug 26, 2026
3afe226
review: address round-27 findings
ThomasK33 Aug 26, 2026
dc4561a
review: address round-28 findings
ThomasK33 Aug 26, 2026
e924e3e
review: address round-29 findings
ThomasK33 Aug 26, 2026
bbeeb2f
review: address round-30 findings
ThomasK33 Aug 26, 2026
09006de
review: address round-31 findings
ThomasK33 Aug 26, 2026
e25a2ca
review: address round-32 findings
ThomasK33 Aug 26, 2026
2e239fa
review: address round-33 findings
ThomasK33 Aug 26, 2026
906d0e6
review: address round-34 findings
ThomasK33 Aug 26, 2026
0577f8e
review: satisfy no-chained-type-assertions in reconcile field merge
ThomasK33 Aug 26, 2026
2dd398b
review: address round-35 findings
ThomasK33 Aug 26, 2026
55ced7c
review: address round-36 finding
ThomasK33 Aug 26, 2026
d30f9e7
review: address round-37 findings
ThomasK33 Aug 26, 2026
6eb75cf
review: address round-38 findings
ThomasK33 Aug 26, 2026
fc02661
review: address round-39 findings
ThomasK33 Aug 26, 2026
145f18c
review: address round-40 findings
ThomasK33 Aug 26, 2026
a5b39a5
review: reject missing workspaces key in strict loads; restrict unsup…
ThomasK33 Aug 26, 2026
9b3c1cc
review: revalidate retained activity entries with post-probe final views
ThomasK33 Aug 26, 2026
d9627f5
review: propagate failed sidecar reconcile on strict snapshot reads (…
ThomasK33 Aug 26, 2026
a7bf4f1
review: use observed nonzero workflow activity, not cache presence, a…
ThomasK33 Aug 26, 2026
5ef0e9b
review: guard seen marker against eviction races, retry config baseli…
ThomasK33 Aug 26, 2026
54b92af
test: de-flake goal kickoff-window test (same-ms consent-stamp collis…
ThomasK33 Aug 27, 2026
70a3324
Merge remote-tracking branch 'origin/main' into activity-list-scoping
ThomasK33 Aug 27, 2026
94b9d42
review: preserve every resolvable legacy identity (alias ids) in dest…
ThomasK33 Aug 27, 2026
cfb6c9f
review: fail closed without a raw baseline; never quarantine over an …
ThomasK33 Aug 27, 2026
901d39d
review: initial-snapshot fallback for late candidates, reconcile side…
ThomasK33 Aug 27, 2026
7908b0f
review: keep the generated-legacy record canonical when both compatib…
ThomasK33 Aug 27, 2026
03e14dd
review: reconcile sidecars before mutations, generation-scoped sideca…
ThomasK33 Aug 27, 2026
2d1eff4
fix: replace stale .recreated leftovers portably and de-flake raw-con…
ThomasK33 Aug 27, 2026
2d48374
fix: address round-51 Codex findings
ThomasK33 Aug 27, 2026
12a4610
fix: address round-52 Codex findings
ThomasK33 Aug 27, 2026
c4d8c8f
fix: address round-53 Codex findings
ThomasK33 Aug 27, 2026
e9e261d
fix: address round-54 Codex findings
ThomasK33 Aug 27, 2026
407d9e2
fix: address round-55 Codex findings
ThomasK33 Aug 27, 2026
d5b016a
fix: persist writes when a tombstone is lifted mid-probe (round 56)
ThomasK33 Aug 27, 2026
c9b5d94
fix: address round-57 Codex findings
ThomasK33 Aug 27, 2026
825b498
fix: address round-58 Codex findings
ThomasK33 Aug 27, 2026
5989d56
fix: address round-59 Codex findings
ThomasK33 Aug 27, 2026
08537d2
fix: address round-60 Codex findings
ThomasK33 Aug 27, 2026
c06fa7a
fix: address round-61 Codex findings
ThomasK33 Aug 27, 2026
9c4e943
fix: address round-62 Codex findings
ThomasK33 Aug 27, 2026
a9d7081
fix: address round-63 Codex finding
ThomasK33 Aug 27, 2026
5e097ed
fix: order equal-recency stranded merges via epoch-ms write stamps
ThomasK33 Aug 27, 2026
a9ddb97
fix: round-66 review — post-load adoption evidence, raw-removal compl…
ThomasK33 Aug 27, 2026
bb84a86
fix: re-enumerate when the post-enumeration raw refresh reveals id-le…
ThomasK33 Aug 27, 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
9 changes: 8 additions & 1 deletion src/cli/trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,14 @@ describe("xum trust CLI", () => {
.quiet();

expect(result.exitCode).not.toBe(0);
expect(result.stderr.toString()).toContain("Failed to persist trust change");
// Either failure surface is acceptable: the corrupt-config write gate
// (config.json exists but cannot be read, so editConfig refuses to write
// defaults over it) or the post-write trust verification (the write was
// silently swallowed). Both must fail loudly instead of reporting
// success.
expect(result.stderr.toString()).toMatch(
/Failed to persist trust change|Skipping config write/
);
expect(result.stdout.toString()).toBe("");
}, 15_000);

Expand Down
369 changes: 369 additions & 0 deletions src/node/config.test.ts

Large diffs are not rendered by default.

465 changes: 436 additions & 29 deletions src/node/config.ts

Large diffs are not rendered by default.

1,968 changes: 1,967 additions & 1 deletion src/node/services/ExtensionMetadataService.test.ts

Large diffs are not rendered by default.

1,880 changes: 1,858 additions & 22 deletions src/node/services/ExtensionMetadataService.ts

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/node/services/coreServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices {
providerService.getConfig()
);
const extensionMetadata = new ExtensionMetadataService(extensionMetadataPath);
// Write tombstones are process-local removal knowledge; the shared config
// is the authority (with XUM_ALLOW_MULTIPLE_INSTANCES a downgraded backend
// can legitimately re-register a deterministic legacy id this process
// pruned). Without this probe, a tombstoned id that becomes active again
// would have every metadata write and broadcast suppressed until an
// activity bootstrap happens to run. Raw view first (cheap; complete when
// every persisted entry carries an inline id); only id-less legacy entries
// require the authoritative enumeration. Throws propagate: unknowable
// registration keeps the tombstone.
extensionMetadata.setRegistrationProbe(async (workspaceId) => {
const evidence = config.readPersistedWorkspaceIdEvidence();
if (evidence.ids.has(workspaceId)) {
return true;
}
if (!evidence.hasWorkspaceEntriesWithoutIds) {
return false;
}
// Targeted lenient positive first: a POSITIVE identity match needs no
// completeness, so a re-registered workspace whose own compatibility
// metadata is healthy must not stay write-suppressed because an
// UNRELATED legacy entry's metadata is malformed (the strict
// enumeration below throws on the first such entry, and the tombstone
// would then pin every one of the target's writes as transient
// indefinitely). A lenient scan only skips unreadable entries — it
// never fabricates a match.
if (config.findWorkspace(workspaceId) != null) {
return true;
}
// Negatives keep requiring the complete strict view: a lenient miss is
// indistinguishable from an identity hidden by a read failure. Alias
// ids: a second resolvable compatibility file's identity stays
// registered for findWorkspace even though it is not any entry's
// primary id — refusing its writes/deletions requires knowing it here.
const legacyAliasIds = new Set<string>();
const registered = (
await config.getAllWorkspaceMetadata({ throwOnError: true, legacyAliasIds })
).some((metadata) => metadata.id === workspaceId);
Comment thread
ThomasK33 marked this conversation as resolved.
return registered || legacyAliasIds.has(workspaceId);
});
const workspaceGoalService = new WorkspaceGoalService(
config,
historyService,
Expand Down
70 changes: 69 additions & 1 deletion src/node/services/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ function createWorkspaceServiceMocks(
emitChatEvent: ReturnType<typeof mock>;
isWorkflowInvocationCurrent: ReturnType<typeof mock>;
create: ReturnType<typeof mock>;
discardExtensionMetadataEntry: ReturnType<typeof mock>;
} {
const sendMessage =
overrides?.sendMessage ?? mock((): Promise<Result<void>> => Promise.resolve(Ok(undefined)));
Expand Down Expand Up @@ -678,10 +679,12 @@ function createWorkspaceServiceMocks(
(): Promise<Result<{ metadata: WorkspaceMetadata }>> =>
Promise.resolve(Err("workspaceService.create not mocked"))
);
const discardExtensionMetadataEntry = mock((): Promise<void> => Promise.resolve());

return {
workspaceService: {
create,
discardExtensionMetadataEntry,
// No-op by default: task-create tests exercise launch flow, not the
// registration-time plugin-override sanitizer (workspaceService.test.ts
// covers it). Returning undefined means "clean".
Expand Down Expand Up @@ -731,6 +734,7 @@ function createWorkspaceServiceMocks(
countQueuedAgentPeerMessages,
} as unknown as WorkspaceService,
create,
discardExtensionMetadataEntry,
sendMessage,
resumeStream,
clearQueue,
Expand Down Expand Up @@ -23801,7 +23805,9 @@ describe("TaskService", () => {
);
const { aiService } = createAIServiceMocks(config);
const failingSendMessage = mock(() => Promise.resolve(Err("send failed")));
const { workspaceService } = createWorkspaceServiceMocks({ sendMessage: failingSendMessage });
const { workspaceService, discardExtensionMetadataEntry } = createWorkspaceServiceMocks({
sendMessage: failingSendMessage,
});
const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService });

const created = await createAgentTask(taskService, parentId, "do the thing");
Expand All @@ -23814,6 +23820,11 @@ describe("TaskService", () => {
.some((w) => w.id === "aaaaaaaaaa");
expect(stillExists).toBe(false);

// Rollback must also drop the extension-metadata entry: the failed send
// may already have scheduled metadata writes that would otherwise leak a
// stale key after deregistration (#3959).
expect(discardExtensionMetadataEntry).toHaveBeenCalledWith("aaaaaaaaaa");

const workspaceName = "agent_explore_aaaaaaaaaa";
const workspacePath = runtime.getWorkspacePath(projectPath, workspaceName);
let workspacePathExists = true;
Expand All @@ -23825,6 +23836,63 @@ describe("TaskService", () => {
expect(workspacePathExists).toBe(false);
}, 20_000);

test("failed config deregistration during rollback does not tombstone the task's metadata", async () => {
const config = await createTestConfig(rootDir);
stubStableIds(config, ["bbbbbbbbbb"], "bbbbbbbbbb");

const projectPath = await createTestProject(rootDir);

const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir };
const runtime = createRuntime(runtimeConfig, { projectPath });
const initLogger = createNullInitLogger();

const parentName = "parent-b";
const parentCreate = await runtime.createWorkspace({
projectPath,
branchName: parentName,
trunkBranch: "main",
directoryName: parentName,
initLogger,
});
expect(parentCreate.success).toBe(true);

const parentId = "2222222222";
const parentPath = runtime.getWorkspacePath(projectPath, parentName);

await saveWorkspaces(
config,
projectPath,
[
{
path: parentPath,
id: parentId,
name: parentName,
createdAt: new Date().toISOString(),
runtimeConfig,
},
],
testTaskSettings()
);
const { aiService } = createAIServiceMocks(config);
const failingSendMessage = mock(() => Promise.resolve(Err("send failed")));
const { workspaceService, discardExtensionMetadataEntry } = createWorkspaceServiceMocks({
sendMessage: failingSendMessage,
});
const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService });
// Deregistration fails: the rollback must NOT discard (and thereby
// write-tombstone) metadata for a workspace that is still registered.
const removeSpy = spyOn(config, "removeWorkspace").mockImplementation(() =>
Promise.reject(new Error("config locked"))
);
try {
const created = await createAgentTask(taskService, parentId, "do the thing");
expect(created.success).toBe(false);
expect(discardExtensionMetadataEntry).not.toHaveBeenCalled();
} finally {
removeSpy.mockRestore();
}
}, 20_000);

test("agent_report posts report to parent, finalizes pending task tool output, and triggers cleanup", async () => {
const config = await createTestConfig(rootDir);

Expand Down
13 changes: 13 additions & 0 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7312,15 +7312,28 @@ export class TaskService {
preservePhysicalWorkspace?: boolean;
}
): Promise<void> {
let removedFromConfig = false;
try {
await this.config.removeWorkspace(taskId);
removedFromConfig = true;
Comment thread
ThomasK33 marked this conversation as resolved.
} catch (error: unknown) {
log.error("Task.create rollback: failed to remove workspace from config", {
taskId,
error: getErrorMessage(error),
});
}

// A create that failed after sendMessage may already have scheduled
// extension-metadata writes (e.g. the recency update), which would
// recreate the entry after the deregistration above and leak a stale key
// until the next process start's lazy prune. Only after deregistration
// actually succeeded: discarding also write-tombstones the id for this
// process, which must not silence metadata for a workspace that is still
// registered because removeWorkspace failed.
if (removedFromConfig) {
await this.workspaceService.discardExtensionMetadataEntry(taskId);
}

this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: null });

if (options?.preservePhysicalWorkspace) {
Expand Down
35 changes: 34 additions & 1 deletion src/node/services/workspaceGoalService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,32 @@ describe("WorkspaceGoalService", () => {
});
});

test("previewStreamAccounting skips the durable fallback when the strict baseline read is unavailable", async () => {
// "unavailable" (failed sidecar reconcile) must stay distinct from the
// authoritative "no baseline": the durable pushSnapshot fallback writes
// through the lenient load — accepting the suspect partial main the
// strict read refused — and emits it, clearing renderer goal/status
// state. The preview must resolve without delivering or writing.
await setGoalOk(service, { workspaceId, objective: "Preview goal" });
const metadataFilePath = path.join(config.rootDir, "extensionMetadata.json");
const before = await fs.readFile(metadataFilePath, "utf-8");
// A directory at the sidecar path yields a deterministic errno (EISDIR)
// standing in for EACCES/EIO-class reconcile failures.
await fs.mkdir(`${metadataFilePath}.corrupt`);
try {
const activityUpdates = captureGoalActivity(service);

const preview = await service.previewStreamAccounting({ workspaceId, costUsd: 1 });

expect(preview).toMatchObject({ objective: "Preview goal" });
// No emit (renderer keeps last-known state) and no durable write.
expect(activityUpdates).toHaveLength(0);
expect(await fs.readFile(metadataFilePath, "utf-8")).toBe(before);
} finally {
await fs.rm(`${metadataFilePath}.corrupt`, { recursive: true });
}
});

test("successful no-op queued drains clear the pending snapshot", async () => {
const created = await setGoalOk(service, { workspaceId, objective: "Existing goal" });
await extensionMetadata.setStreaming(workspaceId, true);
Expand Down Expand Up @@ -5470,7 +5496,14 @@ describe("WorkspaceGoalService", () => {
objective: "Preview without metadata",
budgetCents: 1_000,
});
await extensionMetadata.deleteWorkspace(workspaceId);
// Clear the snapshot by rewriting the file directly: deleteWorkspace now
// write-tombstones removed workspaces for the rest of the process, which
// would (correctly) block the preview persistence below. This test
// simulates a LIVE workspace that merely has no activity snapshot yet.
await fs.writeFile(
path.join(config.rootDir, "extensionMetadata.json"),
JSON.stringify({ version: 1, workspaces: {} })
);
const activityUpdates = captureGoalActivity(service);

const preview = await service.previewStreamAccounting({
Expand Down
37 changes: 29 additions & 8 deletions src/node/services/workspaceGoalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,24 +1280,41 @@ export class WorkspaceGoalService {
private async pushTransientGoalSnapshot(
workspaceId: string,
snapshot: GoalSnapshot
): Promise<boolean> {
const activity = await this.extensionMetadata.getSnapshot(workspaceId);
): Promise<"delivered" | "no_baseline" | "unavailable"> {
let activity: WorkspaceActivitySnapshot | null;
try {
activity = await this.extensionMetadata.getSnapshot(workspaceId, { throwOnError: true });
} catch (error) {
// A suspect baseline (failed sidecar reconcile / unreadable main)
// must not feed an emitted overlay: partial-main fields would clear
// status in the renderer. "unavailable" is deliberately DISTINCT from
// the authoritative "no_baseline": the durable pushSnapshot fallback
// writes through the lenient load (accepting the same suspect partial
// main this strict read refused) and emits the result — converting
// this failure into that fallback would clear exactly the renderer
// state the strict read preserves.
log.debug("Skipping transient goal emit after failed snapshot read", {
workspaceId,
error,
});
return "unavailable";
}
if (!activity) {
// No baseline activity snapshot to overlay the transient goal on
// (extensionMetadata has no entry for this workspace yet). Callers
// that must guarantee delivery — e.g. live cost previews — should
// observe this `false` return and fall back to `pushSnapshot`, which
// observe "no_baseline" and fall back to `pushSnapshot`, which
// creates the entry and emits via the durable path. Pending-goal
// publication does not retry here because it only fires after a
// `setGoal` that already created the entry.
return false;
return "no_baseline";
}
this.onActivityChange?.(workspaceId, {
...activity,
goal: snapshot,
transientGoalOnly: true,
});
return true;
return "delivered";
}

private async pushLiveGoalPreviewOverlay(
Expand Down Expand Up @@ -4114,13 +4131,17 @@ export class WorkspaceGoalService {
});
const snapshot = toGoalSnapshot(preview);
this.liveGoalPreviewSnapshots.set(input.workspaceId, snapshot);
const didEmitTransient = await this.pushTransientGoalSnapshot(input.workspaceId, snapshot);
if (!didEmitTransient) {
const transientResult = await this.pushTransientGoalSnapshot(input.workspaceId, snapshot);
if (transientResult === "no_baseline") {
// If the baseline activity snapshot does not exist yet (for
// example, extensionMetadata was reset or stream-start's
// fire-and-forget metadata write has not finished), fall back to
// the durable path so this preview is still delivered to Goals UI
// subscribers instead of being dropped.
// subscribers instead of being dropped. "unavailable" must NOT take
// this path: the durable write's lenient load would accept the
// suspect partial main the strict read refused and emit it,
// clearing renderer goal/status state — return the computed preview
// without delivery instead (renderer keeps last-known state).
return this.pushSnapshot(input.workspaceId, preview);
}
return snapshot;
Expand Down
Loading
Loading