Skip to content

Commit 273eed4

Browse files
committed
fix(harness): recover Copilot native subagent tasks
1 parent 0bc5fb8 commit 273eed4

7 files changed

Lines changed: 215 additions & 26 deletions

extensions/copilot/src/native-subagent-task-mirror.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,50 @@ describe("CopilotNativeSubagentTaskMirror", () => {
129129
);
130130
});
131131

132+
it("keeps parallel subagents distinct when they share a parent tool call", () => {
133+
const runtime = createRuntime();
134+
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
135+
136+
for (const agentId of ["child-1", "child-2"]) {
137+
mirror.handleEvent(
138+
makeEvent(
139+
"subagent.started",
140+
{
141+
agentDescription: `inspect ${agentId}`,
142+
agentDisplayName: "Researcher",
143+
agentName: "researcher",
144+
toolCallId: "call-shared",
145+
},
146+
agentId,
147+
),
148+
);
149+
}
150+
for (const agentId of ["child-1", "child-2"]) {
151+
mirror.handleEvent(
152+
makeEvent(
153+
"subagent.completed",
154+
{
155+
agentDisplayName: "Researcher",
156+
agentName: "researcher",
157+
toolCallId: "call-shared",
158+
},
159+
agentId,
160+
),
161+
);
162+
}
163+
164+
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
165+
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
166+
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
167+
1,
168+
expect.objectContaining({ runId: "copilot-agent:child-1" }),
169+
);
170+
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
171+
2,
172+
expect.objectContaining({ runId: "copilot-agent:child-2" }),
173+
);
174+
});
175+
132176
it("finalizes active tasks when the parent attempt tears down", () => {
133177
const runtime = createRuntime();
134178
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);

extensions/copilot/src/native-subagent-task-mirror.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ export class CopilotNativeSubagentTaskMirror {
9393
runId: string,
9494
toolCallId: string,
9595
): void {
96-
if (this.runIdByToolCallId.has(toolCallId)) {
96+
const agentId = event.agentId?.trim();
97+
const existingRunId = agentId
98+
? this.runIdByAgentId.get(agentId)
99+
: this.runIdByToolCallId.get(toolCallId);
100+
if (existingRunId) {
97101
return;
98102
}
99103
const eventAt = this.now();
@@ -115,10 +119,10 @@ export class CopilotNativeSubagentTaskMirror {
115119
if (!taskRecord) {
116120
return;
117121
}
118-
this.runIdByToolCallId.set(toolCallId, runId);
119-
const agentId = event.agentId?.trim();
120122
if (agentId) {
121123
this.runIdByAgentId.set(agentId, runId);
124+
} else {
125+
this.runIdByToolCallId.set(toolCallId, runId);
122126
}
123127
this.terminalRunIds.delete(runId);
124128
this.activeRunIds.add(runId);
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
// Runs Codex native subagent tasks and maps their lifecycle into task registry state.
2+
import {
3+
isChildlessNativeSubagentTask,
4+
resolveChildlessNativeSubagentTaskDefinition,
5+
} from "./native-subagent-task.js";
26
import type { TaskRecord } from "./task-registry.types.js";
37

48
/** Runtime label used for Codex-native subagent task records. */
@@ -9,16 +13,8 @@ export const CODEX_NATIVE_SUBAGENT_STALE_ERROR = "Codex native subagent stopped
913

1014
/** Detects native Codex subagent tasks that have no child session to recover from. */
1115
export function isChildlessCodexNativeSubagentTask(task: TaskRecord): boolean {
12-
if (
13-
task.runtime !== CODEX_NATIVE_SUBAGENT_RUNTIME ||
14-
task.taskKind !== CODEX_NATIVE_SUBAGENT_TASK_KIND
15-
) {
16-
return false;
17-
}
18-
if (task.childSessionKey?.trim()) {
19-
return false;
20-
}
21-
return [task.sourceId, task.runId].some((candidate) =>
22-
candidate?.trim().startsWith(CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX),
16+
return (
17+
isChildlessNativeSubagentTask(task) &&
18+
resolveChildlessNativeSubagentTaskDefinition(task)?.taskKind === CODEX_NATIVE_SUBAGENT_TASK_KIND
2319
);
2420
}

src/tasks/native-subagent-task.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Identifies childless native-subagent task rows that are owned by an external
2+
// harness and therefore cannot be recovered through an OpenClaw child session.
3+
import type { TaskRecord } from "./task-registry.types.js";
4+
5+
export const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
6+
export const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
7+
export const COPILOT_NATIVE_SUBAGENT_STALE_ERROR =
8+
"Copilot native subagent stopped reporting progress";
9+
10+
const CHILDLESS_NATIVE_SUBAGENT_DEFINITIONS = [
11+
{
12+
taskKind: "codex-native",
13+
runIdPrefix: "codex-thread:",
14+
},
15+
{
16+
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
17+
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
18+
},
19+
] as const;
20+
21+
export type NativeSubagentTaskDefinition = (typeof CHILDLESS_NATIVE_SUBAGENT_DEFINITIONS)[number];
22+
23+
export function resolveChildlessNativeSubagentTaskDefinition(
24+
task: TaskRecord,
25+
): NativeSubagentTaskDefinition | undefined {
26+
if (task.runtime !== "subagent" || task.childSessionKey?.trim()) {
27+
return undefined;
28+
}
29+
return CHILDLESS_NATIVE_SUBAGENT_DEFINITIONS.find(
30+
(definition) =>
31+
task.taskKind === definition.taskKind &&
32+
[task.sourceId, task.runId].some((candidate) =>
33+
candidate?.trim().startsWith(definition.runIdPrefix),
34+
),
35+
);
36+
}
37+
38+
export function isChildlessNativeSubagentTask(task: TaskRecord): boolean {
39+
return resolveChildlessNativeSubagentTaskDefinition(task) !== undefined;
40+
}

src/tasks/task-registry.maintenance.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ import {
3535
deriveSessionChatTypeFromKey,
3636
type SessionKeyChatType,
3737
} from "../sessions/session-chat-type-shared.js";
38-
import {
39-
CODEX_NATIVE_SUBAGENT_STALE_ERROR,
40-
isChildlessCodexNativeSubagentTask,
41-
} from "./codex-native-subagent-task.js";
38+
import { CODEX_NATIVE_SUBAGENT_STALE_ERROR } from "./codex-native-subagent-task.js";
4239
import {
4340
getDetachedTaskLifecycleRuntime,
4441
tryRecoverTaskBeforeMarkLost,
4542
} from "./detached-task-runtime.js";
43+
import {
44+
isChildlessNativeSubagentTask,
45+
resolveChildlessNativeSubagentTaskDefinition,
46+
} from "./native-subagent-task.js";
4647
import {
4748
deleteTaskRecordById,
4849
ensureTaskRegistryReady,
@@ -71,7 +72,7 @@ import {
7172

7273
const log = createSubsystemLogger("tasks/task-registry-maintenance");
7374
const TASK_RECONCILE_GRACE_MS = 5 * 60_000;
74-
const CHILDLESS_CODEX_NATIVE_RECONCILE_GRACE_MS = 30 * 60_000;
75+
const CHILDLESS_NATIVE_SUBAGENT_RECONCILE_GRACE_MS = 30 * 60_000;
7576
const TASK_STALE_RUNNING_MS = 30 * 60_000;
7677
const TASK_SWEEP_INTERVAL_MS = 60_000;
7778

@@ -326,8 +327,8 @@ function isTerminalTask(task: TaskRecord): boolean {
326327

327328
function hasLostGraceExpired(task: TaskRecord, now: number): boolean {
328329
const referenceAt = task.lastEventAt ?? task.startedAt ?? task.createdAt;
329-
const graceMs = isChildlessCodexNativeSubagentTask(task)
330-
? CHILDLESS_CODEX_NATIVE_RECONCILE_GRACE_MS
330+
const graceMs = isChildlessNativeSubagentTask(task)
331+
? CHILDLESS_NATIVE_SUBAGENT_RECONCILE_GRACE_MS
331332
: TASK_RECONCILE_GRACE_MS;
332333
return now - referenceAt >= graceMs;
333334
}
@@ -498,7 +499,7 @@ function hasBackingSession(task: TaskRecord, context?: BackingSessionLookupConte
498499

499500
const childSessionKey = task.childSessionKey?.trim();
500501
if (!childSessionKey) {
501-
return !isChildlessCodexNativeSubagentTask(task);
502+
return !isChildlessNativeSubagentTask(task);
502503
}
503504
if (task.runtime === "acp") {
504505
// The live-turn map is process-local; only the gateway owns it. A standalone CLI
@@ -527,8 +528,11 @@ function hasBackingSession(task: TaskRecord, context?: BackingSessionLookupConte
527528
}
528529

529530
function resolveTaskLostError(task: TaskRecord, context?: BackingSessionLookupContext): string {
530-
if (isChildlessCodexNativeSubagentTask(task)) {
531-
return CODEX_NATIVE_SUBAGENT_STALE_ERROR;
531+
const nativeDefinition = resolveChildlessNativeSubagentTaskDefinition(task);
532+
if (nativeDefinition) {
533+
return nativeDefinition.taskKind === "codex-native"
534+
? CODEX_NATIVE_SUBAGENT_STALE_ERROR
535+
: "Native subagent stopped reporting progress";
532536
}
533537
if (task.runtime === "subagent") {
534538
const entry = findTaskSessionEntry(task, context);

src/tasks/task-registry.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2421,6 +2421,70 @@ describe("task-registry", () => {
24212421
});
24222422
});
24232423

2424+
it("keeps fresh childless copilot-native subagent tasks live", async () => {
2425+
await withTaskRegistryTempDir(async () => {
2426+
resetTaskRegistryForTests();
2427+
const now = Date.now();
2428+
2429+
const task = createTaskRecord({
2430+
runtime: "subagent",
2431+
taskKind: "copilot-native",
2432+
ownerKey: "agent:main:main",
2433+
scopeKind: "session",
2434+
sourceId: "copilot-agent:child-agent",
2435+
runId: "copilot-agent:child-agent",
2436+
task: "Copilot native child",
2437+
status: "running",
2438+
deliveryStatus: "not_applicable",
2439+
notifyPolicy: "silent",
2440+
lastEventAt: now - 10 * 60_000,
2441+
});
2442+
2443+
expect(await runTaskRegistryMaintenance()).toEqual({
2444+
reconciled: 0,
2445+
recovered: 0,
2446+
cleanupStamped: 0,
2447+
pruned: 0,
2448+
});
2449+
expectRecordFields(requireTaskById(task.taskId), {
2450+
status: "running",
2451+
lastEventAt: now - 10 * 60_000,
2452+
});
2453+
});
2454+
});
2455+
2456+
it("marks stale childless copilot-native subagent tasks lost", async () => {
2457+
await withTaskRegistryTempDir(async () => {
2458+
resetTaskRegistryForTests();
2459+
const now = Date.now();
2460+
2461+
const task = createTaskRecord({
2462+
runtime: "subagent",
2463+
taskKind: "copilot-native",
2464+
ownerKey: "agent:main:main",
2465+
scopeKind: "session",
2466+
sourceId: "copilot-agent:child-agent",
2467+
runId: "copilot-agent:child-agent",
2468+
task: "Copilot native child",
2469+
status: "running",
2470+
deliveryStatus: "not_applicable",
2471+
notifyPolicy: "silent",
2472+
lastEventAt: now - 31 * 60_000,
2473+
});
2474+
2475+
expect(await runTaskRegistryMaintenance()).toEqual({
2476+
reconciled: 1,
2477+
recovered: 0,
2478+
cleanupStamped: 0,
2479+
pruned: 0,
2480+
});
2481+
expectRecordFields(requireTaskById(task.taskId), {
2482+
status: "lost",
2483+
error: "Native subagent stopped reporting progress",
2484+
});
2485+
});
2486+
});
2487+
24242488
it("does not mark unrelated childless subagent tasks lost", async () => {
24252489
await withTaskRegistryTempDir(async () => {
24262490
resetTaskRegistryForTests();
@@ -3794,6 +3858,43 @@ describe("task-registry", () => {
37943858
});
37953859
});
37963860

3861+
it("cancels childless copilot-native tasks without routing through OpenClaw subagent sessions", async () => {
3862+
await withTaskRegistryTempDir(async () => {
3863+
resetTaskRegistryForTests();
3864+
const task = createTaskRecord({
3865+
runtime: "subagent",
3866+
taskKind: "copilot-native",
3867+
ownerKey: "agent:main:main",
3868+
scopeKind: "session",
3869+
sourceId: "copilot-agent:child-agent",
3870+
runId: "copilot-agent:child-agent",
3871+
task: "Copilot native child",
3872+
status: "running",
3873+
deliveryStatus: "not_applicable",
3874+
notifyPolicy: "silent",
3875+
});
3876+
3877+
const result = await cancelTaskById({
3878+
cfg: {} as never,
3879+
taskId: task.taskId,
3880+
});
3881+
3882+
expectRecordFields(result, {
3883+
found: true,
3884+
cancelled: true,
3885+
});
3886+
expectRecordFields(result.task, {
3887+
taskId: task.taskId,
3888+
status: "cancelled",
3889+
endedAt: expect.any(Number),
3890+
lastEventAt: expect.any(Number),
3891+
cleanupAfter: expect.any(Number),
3892+
error: "Cancelled by operator.",
3893+
});
3894+
expect(hoisted.killSubagentRunAdminMock).not.toHaveBeenCalled();
3895+
});
3896+
});
3897+
37973898
it("does not cancel unrelated childless subagent tasks", async () => {
37983899
await withTaskRegistryTempDir(async () => {
37993900
resetTaskRegistryForTests();

src/tasks/task-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
1717
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
1818
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
1919
import { isDeliverableMessageChannel } from "../utils/message-channel.js";
20-
import { isChildlessCodexNativeSubagentTask } from "./codex-native-subagent-task.js";
2120
import { cancelActiveCronTaskRun } from "./cron-task-cancel.js";
21+
import { isChildlessNativeSubagentTask } from "./native-subagent-task.js";
2222
import {
2323
formatTaskBlockedFollowupMessage,
2424
formatTaskStateChangeMessage,
@@ -2092,7 +2092,7 @@ export async function cancelTaskById(params: {
20922092
};
20932093
}
20942094
} else if (!childSessionKey) {
2095-
if (!isChildlessCodexNativeSubagentTask(task)) {
2095+
if (!isChildlessNativeSubagentTask(task)) {
20962096
return {
20972097
found: true,
20982098
cancelled: false,

0 commit comments

Comments
 (0)