Skip to content

Commit e9208a0

Browse files
authored
Merge branch 'main' into threadlines/show-panel-agent-indicators
2 parents 258c69f + 7489fc0 commit e9208a0

116 files changed

Lines changed: 26052 additions & 505 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/src/environment/Layers/ServerEnvironment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
8484
serverVersion: serverConfig.appVersion,
8585
capabilities: {
8686
repositoryIdentity: true,
87+
pullRequests: true,
8788
},
8889
};
8990

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,71 @@ describe("ProviderRuntimeIngestion", () => {
16151615
).toBe("interrupted");
16161616
});
16171617

1618+
it("settles a foreground subagent when its turn completes and leaves a background one running", async () => {
1619+
const harness = await createHarness();
1620+
const turnId = asTurnId("turn-foreground-settle");
1621+
1622+
harness.emit({
1623+
type: "turn.started",
1624+
eventId: asEventId("evt-turn-started-foreground-settle"),
1625+
provider: ProviderDriverKind.make("claudeAgent"),
1626+
threadId: asThreadId("thread-1"),
1627+
createdAt: "2026-01-01T00:00:00.000Z",
1628+
turnId,
1629+
});
1630+
// A foreground agent blocks its turn until it returns, so once the turn
1631+
// has ended on its own the agent's stop event can only be missing. A
1632+
// background agent legitimately keeps working after the turn that
1633+
// spawned it.
1634+
harness.emit({
1635+
type: "subagent.metadata.updated",
1636+
eventId: asEventId("evt-subagent-foreground-running"),
1637+
provider: ProviderDriverKind.make("claudeAgent"),
1638+
threadId: asThreadId("thread-1"),
1639+
createdAt: "2026-01-01T00:00:01.000Z",
1640+
turnId,
1641+
payload: { callId: "call-foreground", status: "running", isBackgrounded: false },
1642+
});
1643+
harness.emit({
1644+
type: "subagent.metadata.updated",
1645+
eventId: asEventId("evt-subagent-background-running"),
1646+
provider: ProviderDriverKind.make("claudeAgent"),
1647+
threadId: asThreadId("thread-1"),
1648+
createdAt: "2026-01-01T00:00:02.000Z",
1649+
turnId,
1650+
payload: { callId: "call-background", status: "running", isBackgrounded: true },
1651+
});
1652+
await waitForThread(
1653+
harness.readModel,
1654+
(thread) =>
1655+
(thread.subagents ?? []).filter((subagent) => subagent.status === "running").length === 2,
1656+
);
1657+
1658+
harness.emit({
1659+
type: "turn.completed",
1660+
eventId: asEventId("evt-turn-completed-foreground-settle"),
1661+
provider: ProviderDriverKind.make("claudeAgent"),
1662+
threadId: asThreadId("thread-1"),
1663+
createdAt: "2026-01-01T00:00:03.000Z",
1664+
turnId,
1665+
payload: { state: "completed" },
1666+
});
1667+
1668+
const thread = await waitForThread(harness.readModel, (entry) =>
1669+
(entry.subagents ?? []).some(
1670+
(subagent) => subagent.spawnCallId === "call-foreground" && subagent.status === "completed",
1671+
),
1672+
);
1673+
const foreground = thread.subagents?.find(
1674+
(subagent) => subagent.spawnCallId === "call-foreground",
1675+
);
1676+
expect(foreground?.status).toBe("completed");
1677+
expect(foreground?.updatedAt).toBe("2026-01-01T00:00:03.000Z");
1678+
expect(
1679+
thread.subagents?.find((subagent) => subagent.spawnCallId === "call-background")?.status,
1680+
).toBe("running");
1681+
});
1682+
16181683
it("applies provider session.state.changed transitions directly", async () => {
16191684
const harness = await createHarness();
16201685
const waitingAt = "2026-01-01T00:00:00.000Z";

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2511,6 +2511,7 @@ const make = Effect.gen(function* () {
25112511
const settleLiveSubagents = Effect.fn("settleLiveSubagents")(function* (options: {
25122512
readonly commandTag: string;
25132513
readonly summary: string;
2514+
readonly status: "completed" | "interrupted";
25142515
readonly belongsToLifecycle: (
25152516
subagent: NonNullable<OrchestrationThread["subagents"]>[number],
25162517
) => boolean;
@@ -2537,7 +2538,7 @@ const make = Effect.gen(function* () {
25372538
payload: {
25382539
...(subagent.agentThreadId ? { agentThreadId: subagent.agentThreadId } : {}),
25392540
...(subagent.spawnCallId ? { callId: subagent.spawnCallId } : {}),
2540-
status: "interrupted",
2541+
status: options.status,
25412542
},
25422543
turnId: subagent.turnId,
25432544
createdAt: event.createdAt,
@@ -2565,6 +2566,7 @@ const make = Effect.gen(function* () {
25652566
yield* settleLiveSubagents({
25662567
commandTag: "subagent-orphan",
25672568
summary: "Subagent no longer tracked by the provider session",
2569+
status: "interrupted",
25682570
belongsToLifecycle: () => true,
25692571
});
25702572
if (event.type === "session.started") {
@@ -2670,13 +2672,29 @@ const make = Effect.gen(function* () {
26702672
(_key, pending) =>
26712673
pending.threadId === thread.id && sameId(pending.event.turnId, eventTurnId),
26722674
);
2673-
}
2674-
if (turnWasInterrupted && shouldApplyThreadLifecycle && eventTurnId !== undefined) {
2675-
yield* settleLiveSubagents({
2676-
commandTag: "subagent-turn-aborted",
2677-
summary: "Subagent interrupted with its parent turn",
2678-
belongsToLifecycle: (subagent) => sameId(subagent.turnId, eventTurnId),
2679-
});
2675+
// An interrupted turn takes every agent of the turn down with it. A
2676+
// turn that ended on its own can only have outlived a foreground
2677+
// agent by losing its stop event, because a foreground spawn blocks
2678+
// the turn until it returns. Background agents (spawned in the
2679+
// background, or moved there mid-run) legitimately keep working after
2680+
// the turn that spawned them, and Codex never says which is which, so
2681+
// only agents the provider marked foreground are settled here.
2682+
yield* settleLiveSubagents(
2683+
turnWasInterrupted
2684+
? {
2685+
commandTag: "subagent-turn-aborted",
2686+
summary: "Subagent interrupted with its parent turn",
2687+
status: "interrupted",
2688+
belongsToLifecycle: (subagent) => sameId(subagent.turnId, eventTurnId),
2689+
}
2690+
: {
2691+
commandTag: "subagent-turn-settled",
2692+
summary: "Subagent settled with its parent turn",
2693+
status: completedTurnState === "completed" ? "completed" : "interrupted",
2694+
belongsToLifecycle: (subagent) =>
2695+
subagent.isBackgrounded === false && sameId(subagent.turnId, eventTurnId),
2696+
},
2697+
);
26802698
}
26812699

26822700
if (

apps/server/src/orchestration/subagentProjection.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,40 @@ function claudeSpawnActivity(input: {
5959
});
6060
}
6161

62+
/** The completion the adapter synthesizes from a `<task-notification>` for an
63+
* agent it holds no spawn for: keyed by the call it knows the agent by, with
64+
* the notification's task id and the agent's final text under `data`. */
65+
function notificationReceipt(input: {
66+
id: string;
67+
toolCallId: string;
68+
taskId: string;
69+
text: string;
70+
createdAt?: string;
71+
}): OrchestrationThreadActivity {
72+
return activity({
73+
id: input.id,
74+
kind: "tool.completed",
75+
...(input.createdAt ? { createdAt: input.createdAt } : {}),
76+
payload: {
77+
itemType: "collab_agent_tool_call",
78+
toolCallId: input.toolCallId,
79+
status: "completed",
80+
title: "Subagent task",
81+
detail: 'Agent "Fix missing-worktree bug trio" finished',
82+
data: {
83+
toolName: "Agent",
84+
input: {},
85+
result: {
86+
type: "tool_result",
87+
tool_use_id: input.toolCallId,
88+
content: [{ type: "text", text: input.text }],
89+
},
90+
taskNotification: { taskId: input.taskId, status: "completed" },
91+
},
92+
},
93+
});
94+
}
95+
6296
describe("projectSubagentActivity", () => {
6397
it("creates a roster row from a Claude Agent tool call", () => {
6498
const roster = projectSubagentActivity(
@@ -415,6 +449,116 @@ describe("projectSubagentActivity", () => {
415449
expect(settled[0]?.objective).toBe("Review");
416450
});
417451

452+
it("ignores a flag-only metadata patch that names no known agent", () => {
453+
const spawned = projectSubagentActivity(
454+
[],
455+
claudeSpawnActivity({
456+
id: "a1",
457+
kind: "tool.started",
458+
status: "inProgress",
459+
turnId: TURN_ID,
460+
}),
461+
);
462+
463+
// A shell command the harness moved to the background reports the same
464+
// flag under its own tool call. It is not an agent and gets no row.
465+
const afterCommand = projectSubagentActivity(
466+
spawned,
467+
activity({
468+
id: "a2",
469+
kind: "subagent.metadata",
470+
payload: { callId: "toolu_bash_background", isBackgrounded: true },
471+
}),
472+
);
473+
expect(afterCommand).toHaveLength(1);
474+
475+
// A resumed agent restates its depth and background flags under the call
476+
// that resumed it. Still one agent, and nothing to key a second row by.
477+
const afterResumeFlags = projectSubagentActivity(
478+
afterCommand,
479+
activity({
480+
id: "a3",
481+
kind: "subagent.metadata",
482+
turnId: RESUME_TURN_ID,
483+
payload: { callId: "toolu_01SendMessageResume", treeDepth: 1, isBackgrounded: true },
484+
}),
485+
);
486+
expect(afterResumeFlags).toHaveLength(1);
487+
488+
// The same flags addressed to the spawn still land on its row.
489+
const flagged = projectSubagentActivity(
490+
afterResumeFlags,
491+
activity({
492+
id: "a4",
493+
kind: "subagent.metadata",
494+
payload: { callId: SPAWN_TOOL_USE_ID, isBackgrounded: true },
495+
}),
496+
);
497+
expect(flagged).toHaveLength(1);
498+
expect(flagged[0]?.isBackgrounded).toBe(true);
499+
expect(flagged[0]?.status).toBe("running");
500+
});
501+
502+
it("files a resumed agent's replayed report on its row by task id", () => {
503+
const spawned = projectSubagentActivity(
504+
[],
505+
claudeSpawnActivity({
506+
id: "a1",
507+
kind: "tool.started",
508+
status: "inProgress",
509+
turnId: TURN_ID,
510+
}),
511+
);
512+
const linked = projectSubagentActivity(
513+
spawned,
514+
activity({
515+
id: "a2",
516+
kind: "task.started",
517+
turnId: TURN_ID,
518+
payload: {
519+
taskId: "a53e9dad4acb0ffce",
520+
toolUseId: SPAWN_TOOL_USE_ID,
521+
taskType: "local_agent",
522+
subagentType: "claude",
523+
},
524+
}),
525+
);
526+
527+
// After a restart the adapter holds no spawn for the agent and files its
528+
// final report as a completion of the call that resumed it.
529+
const reported = projectSubagentActivity(
530+
linked,
531+
notificationReceipt({
532+
id: "a3",
533+
toolCallId: "toolu_01SendMessageResume",
534+
taskId: "a53e9dad4acb0ffce",
535+
text: "## Report\n\nStep 4a done.",
536+
createdAt: "2026-08-15T00:10:00.000Z",
537+
}),
538+
);
539+
expect(reported).toHaveLength(1);
540+
expect(reported[0]).toMatchObject({
541+
spawnCallId: SPAWN_TOOL_USE_ID,
542+
status: "completed",
543+
resultBody: "## Report\n\nStep 4a done.",
544+
objective: "Fix missing-worktree bug trio",
545+
});
546+
547+
// A report naming no known agent stands for itself: the output is kept.
548+
const orphan = projectSubagentActivity(
549+
reported,
550+
notificationReceipt({
551+
id: "a4",
552+
toolCallId: "toolu_01Unknown",
553+
taskId: "unknown-task",
554+
text: "Lost agent output.",
555+
createdAt: "2026-08-15T00:20:00.000Z",
556+
}),
557+
);
558+
expect(orphan).toHaveLength(2);
559+
expect(orphan[1]?.resultBody).toBe("Lost agent output.");
560+
});
561+
418562
it("still folds Codex-shaped collab items", () => {
419563
const roster = projectSubagentActivity(
420564
[],

apps/server/src/orchestration/subagentProjection.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
} from "@threadlines/contracts";
88
import {
99
claudeSubagentActivityItem,
10+
claudeSubagentNotificationTaskId,
11+
isClaudeAgentTaskPayload,
1012
isClaudeSubagentToolName,
1113
isSpawnAgentTool,
1214
} from "@threadlines/shared/claudeSubagentActivity";
@@ -107,6 +109,8 @@ interface SubagentPatch {
107109
readonly reasoningEffortProvenance?: OrchestrationSubagentSettingProvenance | null;
108110
readonly resultBody?: string | null;
109111
readonly resultCreatedAt?: string | null;
112+
/** Task id of the notification a replayed agent result was built from. */
113+
readonly notificationTaskId?: string | null;
110114
}
111115

112116
function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | null {
@@ -197,6 +201,7 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] {
197201
const agentPath = text(item.agentPath);
198202
const requestedModel = text(item.model);
199203
const reasoningEffort = text(item.reasoningEffort);
204+
const notificationTaskId = claudeSubagentNotificationTaskId(data);
200205
return ids.map((id) => {
201206
const state = record(states?.[id]);
202207
return {
@@ -225,6 +230,7 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] {
225230
reasoningEffortProvenance: reasoningEffort ? "explicit" : null,
226231
resultBody: text(state?.message),
227232
resultCreatedAt: text(state?.message) ? activity.createdAt : null,
233+
notificationTaskId,
228234
};
229235
});
230236
}
@@ -310,16 +316,6 @@ function settleTaskCompletion(
310316
return next;
311317
}
312318

313-
const AGENT_TASK_TYPES = new Set(["local_agent", "remote_agent"]);
314-
315-
/** Background command tasks share the task activity kinds with agent tasks and
316-
* must never move an agent's row. */
317-
function isAgentTaskActivity(payload: UnknownRecord | null): boolean {
318-
return (
319-
text(payload?.subagentType) !== null || AGENT_TASK_TYPES.has(text(payload?.taskType) ?? "")
320-
);
321-
}
322-
323319
function isSettledStatus(status: OrchestrationSubagentStatus): boolean {
324320
return status === "completed" || status === "failed" || status === "interrupted";
325321
}
@@ -341,7 +337,7 @@ function reopenResumedAgentRun(
341337
activity: OrchestrationThreadActivity,
342338
): ReadonlyArray<OrchestrationSubagent> {
343339
const payload = record(activity.payload);
344-
if (!isAgentTaskActivity(payload)) return current;
340+
if (!isClaudeAgentTaskPayload(payload)) return current;
345341
const taskId = text(payload?.taskId);
346342
const toolUseId = text(payload?.toolUseId);
347343
const index = current.findIndex(
@@ -423,6 +419,35 @@ export function projectSubagentActivity(
423419
matches.push(index);
424420
}
425421
}
422+
// A replayed final report is filed under the call the adapter knows the
423+
// agent by. After a provider restart that is the call that resumed the
424+
// agent, which owns no row; the task id it carries still names the agent.
425+
// Only the lifecycle lands there: the synthesized item knows nothing else
426+
// about the agent. A report naming no known agent stands for itself below,
427+
// so the agent's only output is kept.
428+
if (matches.length === 0 && patch.notificationTaskId) {
429+
const owner = next.findIndex((entry) => entry.transcriptAgentId === patch.notificationTaskId);
430+
const row = owner >= 0 ? next[owner] : undefined;
431+
if (row) {
432+
next[owner] = mergeSubagent(
433+
row,
434+
{
435+
id: row.id,
436+
...(patch.status === undefined ? {} : { status: patch.status }),
437+
resultBody: patch.resultBody ?? null,
438+
resultCreatedAt: patch.resultCreatedAt ?? null,
439+
},
440+
activity,
441+
);
442+
continue;
443+
}
444+
}
445+
// A patch that states no lifecycle status (a background flag, a nesting
446+
// depth) describes an agent some other activity introduced. When none did
447+
// — a shell command the harness moved to the background, a resume call
448+
// for a spawn this process never saw — there is no agent to describe, and
449+
// a `pending:` row keyed by that call would never receive a stop.
450+
if (matches.length === 0 && patch.status === undefined) continue;
426451
const [primary, ...absorbed] = matches;
427452
let base = primary !== undefined ? next[primary] : undefined;
428453
for (const index of absorbed) {

0 commit comments

Comments
 (0)