Improve the archived workspace restore flow (#2002)

* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
This commit is contained in:
Mohamed Boudra
2026-07-16 17:22:37 +02:00
committed by GitHub
parent d42ab91971
commit 5da6548aff
47 changed files with 1769 additions and 823 deletions

View File

@@ -840,6 +840,18 @@ export const WorkspacePinSetRequestSchema = z.object({
requestId: z.string(),
});
export const WorkspaceRecoveryInspectRequestSchema = z.object({
type: z.literal("workspace.recovery.inspect.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const WorkspaceRecoveryRestoreRequestSchema = z.object({
type: z.literal("workspace.recovery.restore.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -1473,6 +1485,40 @@ export const WorkspacePinSetResponseSchema = z.object({
payload: WorkspacePinSetResponsePayloadSchema,
});
export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("recoverable"),
workspaceId: z.string(),
workspaceName: z.string(),
action: z.string(),
branch: z.string().nullable(),
}),
z.object({
kind: z.literal("unavailable"),
workspaceId: z.string(),
reason: z.string(),
message: z.string(),
}),
]);
export const WorkspaceRecoveryInspectResponseSchema = z.object({
type: z.literal("workspace.recovery.inspect.response"),
payload: z.object({
requestId: z.string(),
state: WorkspaceRecoveryStateSchema,
}),
});
export const WorkspaceRecoveryRestoreResponseSchema = z.object({
type: z.literal("workspace.recovery.restore.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -2145,6 +2191,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema,
WorkspacePinSetRequestSchema,
WorkspaceRecoveryInspectRequestSchema,
WorkspaceRecoveryRestoreRequestSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -2451,6 +2499,8 @@ export const ServerInfoStatusPayloadSchema = z
projectAdd: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(),
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -4500,6 +4550,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema,
WorkspacePinSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
@@ -4664,6 +4716,13 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
>;
export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>;
export type WorkspacePinSetResponsePayload = z.infer<typeof WorkspacePinSetResponsePayloadSchema>;
export type WorkspaceRecoveryState = z.infer<typeof WorkspaceRecoveryStateSchema>;
export type WorkspaceRecoveryInspectResponse = z.infer<
typeof WorkspaceRecoveryInspectResponseSchema
>;
export type WorkspaceRecoveryRestoreResponse = z.infer<
typeof WorkspaceRecoveryRestoreResponseSchema
>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
@@ -4797,6 +4856,8 @@ export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "vitest";
import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";
describe("workspace recovery protocol", () => {
test("parses the read-only recovery inspection exchange", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.inspect.request",
requestId: "inspect-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.inspect.request" });
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-1",
state: {
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
},
},
}),
).toMatchObject({
payload: { state: { kind: "recoverable", action: "restore" } },
});
});
test("parses explicit restore success and retryable failure responses", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.restore.request",
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.restore.request" });
for (const payload of [
{
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
accepted: true,
error: null,
},
{
requestId: "restore-2",
workspaceId: "wks_15a1b5630ebaab33",
accepted: false,
error: "Project root is missing: /repo",
},
]) {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.restore.response",
payload,
}),
).toMatchObject({ payload });
}
});
test("keeps the capability optional for older daemons", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "old-host",
features: {},
}).features?.workspaceRecovery,
).toBeUndefined();
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "new-host",
features: { workspaceRecovery: true },
}).features?.workspaceRecovery,
).toBe(true);
});
test("accepts unavailable reasons added by newer daemons", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-future",
state: {
kind: "unavailable",
workspaceId: "workspace-1",
reason: "future_recovery_constraint",
message: "This host cannot recover the workspace yet.",
},
},
}),
).toMatchObject({
payload: {
state: {
kind: "unavailable",
reason: "future_recovery_constraint",
},
},
});
});
test("accepts recovery actions added by newer daemons", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-future-action",
state: {
kind: "recoverable",
workspaceId: "workspace-1",
workspaceName: "Feature branch",
action: "repair_from_snapshot",
branch: "feature",
},
},
}),
).toMatchObject({
payload: {
state: {
kind: "recoverable",
action: "repair_from_snapshot",
},
},
});
});
});