Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 0 additions & 52 deletions apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,58 +28,6 @@ describe("ThreadBackgroundLiveness", () => {
expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
});

it("does not let status-free updated restart an idle task", () => {
const liveness = ThreadBackgroundLiveness.make();
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: undefined,
kind: "started",
});
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: "idle",
kind: "updated",
});
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: undefined,
kind: "updated",
});
expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
});

it("a genuine restart (kind: started) still revives an idle task", () => {
const liveness = ThreadBackgroundLiveness.make();
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: undefined,
kind: "started",
});
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: "idle",
kind: "updated",
});
liveness.recordTaskLiveness({
threadId: "thread",
taskId: "task",
taskType: undefined,
status: undefined,
kind: "started",
});
expect(liveness.getThreadBackgroundLiveness("thread")).toBe("working");
});

it("agents present as working; monitors as monitoring; agents win", () => {
const liveness = ThreadBackgroundLiveness.make();
const threadId = "t-live-1";
Expand Down
9 changes: 4 additions & 5 deletions apps/server/src/orchestration/ThreadBackgroundLiveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,10 @@ export function make(): ThreadBackgroundLivenessService["Service"] {
return;
}

// Status-free progress/updated is a description tick, not a restart. A
// delayed progress or updated event after idle must not put the task
// back in the live set (#7128, #7172). "started" is excluded on
// purpose: a genuine restart must still revive the task.
if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) {
// Status-free progress is a description tick, not a restart. A delayed
// progress event after idle must not put the task back in the live set
// (#7128).
if (input.kind === "progress" && input.status === undefined) {
Comment on lines +133 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep status-free updates from reviving idle tasks

When Claude emits a task_updated with status: paused followed by a metadata-only update such as description or is_backgrounded, the first event maps to idle and removes the task, but this narrowed guard lets the second status-free updated event fall through and add it back as live. The sidebar then remains incorrectly pinned to Working until another terminal update or session exit; ClaudeAdapter.ts:3275-3301 explicitly permits these status-free patches. Retain the same live-set guard for updated events as for progress.

AGENTS.md reference: AGENTS.md:L146-L149

Useful? React with 👍 / 👎.

const existing = stateByThreadId.get(input.threadId);
const stillLive =
existing !== undefined &&
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
);
}),
);

it.effect("maps completed agent message items to canonical item.completed events", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,6 @@ describe("CodexSessionRuntime collab integration", () => {
assert.isDefined(registrationA);
assert.isDefined(registrationB);
assert.isDefined(rootThreadStarted);
const interactedRegistrationA = {
...registrationA,
params: {
...registrationA.params,
item: { ...registrationA.params.item, kind: "interacted" },
},
};
const memoryThreadStarted = {
...rootThreadStarted,
params: {
Expand All @@ -214,7 +207,7 @@ describe("CodexSessionRuntime collab integration", () => {
hangInterruptFor: CHILD_A,
notifications: [
turnStartedA,
interactedRegistrationA,
registrationA,
memoryThreadStarted,
memoryTurnStarted,
registrationB,
Expand All @@ -240,38 +233,26 @@ describe("CodexSessionRuntime collab integration", () => {
environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
});

// Wait for both children's synthetic turnStarted signals before
// stopping. B arrives through the registered-child path; A is replayed
// when its later activity registration finds the pre-registration live
// turn recorded by the foreign-notification suppressor.
const childrenStartedFiber = yield* runtime.events.pipe(
// Wait for both children's turnStarted signals to be processed before
// stopping (B via the registered-child path; A only produces live-turn
// bookkeeping, so key on B's synthetic event).
const childBStartedFiber = yield* runtime.events.pipe(
Stream.filter(
(event) =>
event.method === "collabAgent/turnStarted" &&
[CHILD_A, CHILD_B].includes(
(event.payload as { agentThreadId?: string }).agentThreadId ?? "",
),
(event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B,
),
Stream.take(2),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
);

yield* runtime.start();
yield* runtime.sendTurn({ input: "fan out and hang" });
const childrenStarted = yield* Fiber.join(childrenStartedFiber).pipe(
const childBStarted = yield* Fiber.join(childBStartedFiber).pipe(
Effect.timeoutOption("15 seconds"),
);
assert.isTrue(childrenStarted._tag === "Some", "child turnStarted replay never arrived");
if (childrenStarted._tag === "Some") {
const startedThreadIds = new Set(
Array.from(childrenStarted.value).map(
(event) => (event.payload as { agentThreadId?: string }).agentThreadId,
),
);
assert.isTrue(startedThreadIds.has(CHILD_A), "child A start must replay on registration");
assert.isTrue(startedThreadIds.has(CHILD_B), "child B start must flow after registration");
}
assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived");

// Stop everything. A's interrupt hangs forever — the bounded child
// deadline must expire and the parent interrupt must still be sent.
Expand Down
37 changes: 7 additions & 30 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,8 +1107,8 @@ export const makeCodexSessionRuntime = (
return false;
}
const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined;
const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId);
yield* Ref.update(collabChildAgentsRef, (current) => {
const existing = current.get(item.agentThreadId);
const next = new Map(current);
// Merge-late semantics: when thread/started registered first, a
// later subAgentActivity still carries the real agentPath (and a
Expand All @@ -1120,13 +1120,13 @@ export const makeCodexSessionRuntime = (
next.set(item.agentThreadId, {
agentThreadId: item.agentThreadId,
nickname:
existingChild?.nickname ??
existing?.nickname ??
item.agentPath.split("/").findLast((segment) => segment.length > 0),
role: existingChild?.role,
agentPath: existingChild?.agentPath ?? item.agentPath,
depth: existingChild?.depth,
parentThreadId: existingChild?.parentThreadId,
spawnTurnId: existingChild ? existingChild.spawnTurnId : activitySpawnTurnId,
role: existing?.role,
agentPath: existing?.agentPath ?? item.agentPath,
depth: existing?.depth,
parentThreadId: existing?.parentThreadId,
spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId,
});
return next;
});
Expand All @@ -1142,29 +1142,6 @@ export const makeCodexSessionRuntime = (
activityKind: item.kind,
},
});
// A child turn can start before this activity registers the child.
// The foreign-notification suppressor records that live turn but
// cannot emit agent lifecycle until identity is known. Replay the
// explicit start after first registration so sidebar liveness sees
// genuine work; a trailing interaction with no live turn remains
// the status-free metadata update mapped by CodexAdapter.
const preRegistrationLiveTurn = (yield* Ref.get(collabChildLiveTurnsRef)).get(
item.agentThreadId,
);
if (!existingChild && item.kind === "interacted" && preRegistrationLiveTurn) {
yield* emitEvent({
kind: "notification",
threadId: options.threadId,
...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}),
method: "collabAgent/turnStarted",
payload: {
agentThreadId: item.agentThreadId,
...(registeredChild?.nickname ? { nickname: registeredChild.nickname } : {}),
...(registeredChild?.role ? { role: registeredChild.role } : {}),
agentPath: item.agentPath,
},
});
}
return true;
Comment on lines 1144 to 1145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pre-registration child starts

When a child turn/started arrives before registration and its first subAgentActivity is interacted, the foreign-notification suppressor records and discards that start, but this path now returns without replaying it after registration. CodexAdapter.ts:593-595 intentionally emits nothing for interacted, so no task.started or running update reaches liveness and the thread can appear idle while the child is still working. Replay the recorded live turn for this first-registration case before returning.

AGENTS.md reference: AGENTS.md:L146-L149

Useful? React with 👍 / 👎.

}

Expand Down