Let Hub finish the executions it starts (#2395)

* feat(server): control Hub execution lifecycle

* fix(server): archive only execution-owned worktrees

* fix(server): serialize Hub execution control after create

* test(server): compare Hub worktree paths canonically

* fix(server): make absent Hub controls idempotent

A Hub can own an execution before agent creation materializes. Treating an absent daemon-scoped record as already stopped or archived lets finality complete without revealing or affecting another daemon's execution.
This commit is contained in:
Mohamed Boudra
2026-07-24 22:03:33 +02:00
committed by GitHub
parent 457679d45a
commit be52347d67
12 changed files with 541 additions and 52 deletions

View File

@@ -36,7 +36,7 @@ const agent = {
labels: {},
};
// Frozen at the Hub create request shape shipped before worktree and autoArchive.
// Frozen at the Hub create request shape shipped before worktree.
const PreviousHubAgentCreateRequestSchema = z.object({
type: z.literal("hub.execution.agent.create.request"),
requestId: z.string(),
@@ -52,6 +52,19 @@ const PreviousHubAgentCreateRequestSchema = z.object({
env: z.record(z.string(), z.string()).optional(),
});
// Frozen at the Hub create request shape that temporarily carried turn-based auto-archive policy.
const PreviousHubAgentCreateWithAutoArchiveRequestSchema =
PreviousHubAgentCreateRequestSchema.extend({
worktree: z
.object({
mode: z.literal("branch-off"),
newBranch: z.string(),
base: z.string().optional(),
})
.optional(),
autoArchive: z.boolean().optional(),
});
describe("Hub session protocol", () => {
test("accepts the Hub execution create request", () => {
const message = {
@@ -80,12 +93,64 @@ describe("Hub session protocol", () => {
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
...(worktree ? { worktree, autoArchive: true } : {}),
...(worktree ? { worktree } : {}),
};
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
test("accepts old Hub creates while ignoring their removed auto-archive policy", () => {
const oldRequest = {
type: "hub.execution.agent.create.request" as const,
requestId: "hub-old-policy",
executionId: "execution-old-policy",
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
worktree: { mode: "branch-off" as const, newBranch: "hub-work", base: "main" },
autoArchive: true,
};
expect(PreviousHubAgentCreateWithAutoArchiveRequestSchema.parse(oldRequest)).toEqual(
oldRequest,
);
expect(SessionInboundMessageSchema.parse(oldRequest)).toEqual({
type: "hub.execution.agent.create.request",
requestId: "hub-old-policy",
executionId: "execution-old-policy",
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
worktree: { mode: "branch-off", newBranch: "hub-work", base: "main" },
});
});
test.each(["interrupt", "archive"] as const)(
"round-trips the Hub execution %s command",
(action) => {
const request = {
type: "hub.execution.control.request" as const,
requestId: `control-${action}`,
executionId: "execution-1",
action,
};
const response = {
type: "hub.execution.control.response" as const,
payload: {
requestId: request.requestId,
executionId: request.executionId,
action,
success: true,
error: null,
},
};
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
expect(parseHubExecutionOutboundMessage(response)).toEqual(response);
},
);
test("the previous Hub create parser ignores additive worktree and auto-archive fields", () => {
const newRequest = {
type: "hub.execution.agent.create.request" as const,
@@ -120,6 +185,16 @@ describe("Hub session protocol", () => {
error: null,
},
},
{
type: "hub.execution.control.response",
payload: {
requestId: "control-1",
executionId: "execution-1",
action: "archive",
success: false,
error: "Execution not found",
},
},
{
type: "hub.execution.agent.update",
payload: { executionId: "execution-1", agentId: "agent-1", agent },

View File

@@ -2418,13 +2418,25 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
featureValues: z.record(z.string(), z.unknown()).optional(),
env: z.record(z.string(), z.string()).optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
});
export type HubExecutionAgentCreateRequest = z.infer<typeof HubExecutionAgentCreateRequestSchema>;
export const HubExecutionControlActionSchema = z.enum(["interrupt", "archive"]);
export type HubExecutionControlAction = z.infer<typeof HubExecutionControlActionSchema>;
export const HubExecutionControlRequestSchema = z.object({
type: z.literal("hub.execution.control.request"),
requestId: z.string(),
executionId: z.string(),
action: HubExecutionControlActionSchema,
});
export type HubExecutionControlRequest = z.infer<typeof HubExecutionControlRequestSchema>;
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateRequestSchema,
HubExecutionControlRequestSchema,
BrowserAutomationExecuteResponseSchema,
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -5079,6 +5091,17 @@ export const HubExecutionAgentCreateResponseSchema = z.object({
}),
});
export const HubExecutionControlResponseSchema = z.object({
type: z.literal("hub.execution.control.response"),
payload: z.object({
requestId: z.string(),
executionId: z.string(),
action: HubExecutionControlActionSchema,
success: z.boolean(),
error: z.string().nullable(),
}),
});
export const HubExecutionAgentUpdateSchema = z.object({
type: z.literal("hub.execution.agent.update"),
payload: z.object({
@@ -5098,11 +5121,13 @@ export const HubExecutionAgentStreamSchema = z.object({
});
export type HubExecutionAgentCreateResponse = z.infer<typeof HubExecutionAgentCreateResponseSchema>;
export type HubExecutionControlResponse = z.infer<typeof HubExecutionControlResponseSchema>;
export type HubExecutionAgentUpdate = z.infer<typeof HubExecutionAgentUpdateSchema>;
export type HubExecutionAgentStream = z.infer<typeof HubExecutionAgentStreamSchema>;
export const HubExecutionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionControlResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
]);
@@ -5135,6 +5160,7 @@ export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMes
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionControlResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
BrowserAutomationExecuteRequestSchema,