Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
69a7f1c
fix(server): phones get the approval push when a subagent waits
Oct 23, 2026
e17084b
fix(server): re-read waiting subagents before publishing their parent
Oct 23, 2026
1a89e06
fix(server): a second subagent wait gets its own phone alert
Oct 23, 2026
0b37f85
fix(server): a subagent request gets a fresh publish retry budget
Oct 23, 2026
a3b9a8f
fix(server): keep subagent requests that start while publishing is off
Oct 23, 2026
b48f1c9
fix(server): track paused subagent requests after an empty startup
Oct 23, 2026
e98fac6
fix(server): a subagent's next request of the same kind re-alerts
Oct 23, 2026
4c7b466
fix(server): key subagent waits by request id and the child's update …
Oct 23, 2026
34bfc4a
fix(server): a second subagent that starts waiting gets its own alert
Oct 23, 2026
347c7e5
fix(server): give overlapping child approvals distinct alert times
Oct 24, 2026
2e78c26
fix(server): alert for child requests with matching timestamps
Oct 24, 2026
b406b70
Merge remote-tracking branch 'origin/main' into issue-watch/24053
Oct 25, 2026
fb50dd3
fix(server): a child's next wait never reuses an earlier alert time
Oct 25, 2026
6e0994e
docs(server): say why the relay's subagent helpers are untraced
Oct 25, 2026
013d78a
Merge remote-tracking branch 'github/main' into takeover-24079
maria-rcks Oct 26, 2026
6469a0c
refactor(server): trim subagent relay bookkeeping
maria-rcks Oct 26, 2026
3d8d460
chore: merge main into subagent approval fix
maria-rcks Oct 30, 2026
7067b99
fix(server): preserve child approval awareness after main merge
maria-rcks Oct 30, 2026
463ad6b
chore: merge main into subagent approval fix
maria-rcks Oct 31, 2026
88fd4f3
fix(server): carry child approvals through side chat ancestors
maria-rcks Oct 31, 2026
b6034e4
chore: merge main into subagent approval fix
maria-rcks Nov 1, 2026
a9fdb0f
chore: merge main into subagent approval fix
shivamhwp Nov 7, 2026
cd6c07f
fix(server): an unchanged child approval keeps its alert time
shivamhwp Nov 7, 2026
1d1e3e9
fix(server): a parent's own request doesn't reset a child's alert time
shivamhwp Nov 7, 2026
68f7603
Merge remote-tracking branch 'origin/main' into prbatch/24079
shivamhwp Nov 9, 2026
ff19c7c
refactor(server): trim subagent approval push to what the fix needs
shivamhwp Nov 9, 2026
ea1e68b
fix(server): a relink republishes a parent whose child still waits
shivamhwp Nov 9, 2026
70791c7
Merge branch 'main' into fix/subagent-approval-push
shivamhwp Nov 9, 2026
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
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] &
Pick<
OrchestrationV2ThreadShell,
| "projectId"
| "creationSource"
| "latestRunCompletedAt"
| "archivedAt"
| "activeRunId"
Expand Down Expand Up @@ -7173,6 +7174,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
Effect.map((state): AgentAwarenessThread => ({
id: state.thread.id,
projectId: state.thread.projectId,
creationSource: state.thread.creationSource,
archivedAt: state.thread.archivedAt,
activeRunId: state.activeRunId,
latestRunId: state.latestRunId,
Expand Down
132 changes: 132 additions & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ProjectId,
ProviderInstanceId,
RunId,
RuntimeRequestId,
ThreadId,
TurnItemId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -93,6 +94,31 @@ function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): Orchestrati
};
}

const CHILD_THREAD_ID = ThreadId.make("relay-child-thread");

function waitingChild(
overrides: Partial<OrchestrationV2ThreadShell> = {},
): OrchestrationV2ThreadShell {
return shell({
id: CHILD_THREAD_ID,
title: "Child",
lineage: {
rootThreadId: THREAD_ID,
parentThreadId: THREAD_ID,
relationshipToParent: "subagent",
},
createdBy: "agent",
creationSource: "mcp",
activityRunStatus: "running",
pendingRuntimeRequest: {
id: RuntimeRequestId.make("child-request"),
kind: "command",
createdAt: DateTime.makeUnsafe(NOW),
},
...overrides,
});
}

const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) });
const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload));
const unused = () => Effect.die("Unexpected test dependency call");
Expand Down Expand Up @@ -305,6 +331,112 @@ const makeTestRelay = Effect.fnUntraced(function* (
});

describe("AgentAwarenessRelay", () => {
it.effect("publishes the parent as needing approval while its subagent waits", () =>
Effect.gen(function* () {
const shells = yield* Ref.make(
new Map<ThreadId, OrchestrationV2ThreadShell>([
[THREAD_ID, shell({ status: "running" })],
[CHILD_THREAD_ID, waitingChild({ pendingRuntimeRequest: null })],
]),
);
const setShell = (next: OrchestrationV2ThreadShell) =>
Ref.update(shells, (current) => new Map(current).set(next.id, next));
const { relay, publications } = yield* makeTestRelay({
readShell: (threadId) =>
Ref.get(shells).pipe(Effect.map((current) => current.get(threadId) ?? null)),
});
yield* relay.publishThread(THREAD_ID);
yield* setShell(shell({ status: "completed" }));
yield* setShell(waitingChild());
yield* relay.publishThread(CHILD_THREAD_ID);
assert.equal(publications.length, 2);
assert.isTrue(publications[1]?.url.includes(`/threads/${THREAD_ID}/`) ?? false);
assert.equal(publications[1]?.state?.phase, "waiting_for_approval");

// A rename keeps the same request's alert time, so the phone doesn't re-alert.
yield* setShell(
shell({
status: "completed",
title: "Renamed thread",
updatedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"),
}),
);
yield* relay.publishThread(THREAD_ID);
assert.equal(publications.length, 3);
assert.equal(publications[2]?.state?.threadTitle, "Renamed thread");
assert.equal(publications[2]?.state?.updatedAt, publications[1]?.state?.updatedAt);

yield* setShell(waitingChild({ pendingRuntimeRequest: null }));
yield* relay.publishThread(CHILD_THREAD_ID);
yield* TestClock.adjust("5 seconds");
yield* relay.drain;
assert.equal(publications.length, 4);
assert.equal(publications[3]?.state?.phase, "completed");
}),
);

it.effect("republishes a completed parent for its waiting child after a relink", () =>
Effect.gen(function* () {
const parent = shell({ status: "completed" });
const child = waitingChild();
const { relay, secrets, publications } = yield* makeTestRelay({
catchUpThreads: [parent, child],
readShell: (id) => Effect.succeed(id === child.id ? child : parent),
});
yield* relay.start();
yield* TestClock.adjust("1 second");
yield* relay.drain;
assert.equal(publications.length, 1);
assert.equal(publications[0]?.state?.phase, "waiting_for_approval");

yield* secrets.set(
RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
new TextEncoder().encode("credential-2"),
);
yield* relay.requestCatchUp();
yield* TestClock.adjust("1 second");
yield* relay.drain;
assert.equal(publications.length, 2);
assert.equal(publications[1]?.state?.phase, "waiting_for_approval");
}).pipe(Effect.scoped),
);

it.effect("keeps a child's alert time when the parent's own request comes and goes", () =>
Effect.gen(function* () {
const shells = yield* Ref.make(
new Map<ThreadId, OrchestrationV2ThreadShell>([
[THREAD_ID, shell()],
[CHILD_THREAD_ID, waitingChild()],
]),
);
const setParent = (next: OrchestrationV2ThreadShell) =>
Ref.update(shells, (current) => new Map(current).set(THREAD_ID, next));
const { relay, publications } = yield* makeTestRelay({
readShell: (threadId) =>
Ref.get(shells).pipe(Effect.map((current) => current.get(threadId) ?? null)),
});
yield* relay.publishThread(CHILD_THREAD_ID);
yield* setParent(
shell({
updatedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"),
pendingRuntimeRequest: {
id: RuntimeRequestId.make("parent-request"),
kind: "command",
createdAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"),
},
}),
);
yield* relay.publishThread(THREAD_ID);
yield* setParent(shell({ updatedAt: DateTime.makeUnsafe("2026-09-04T12:02:00.000Z") }));
yield* relay.publishThread(THREAD_ID);
assert.deepStrictEqual(
publications.map(({ state }) => state?.phase),
["waiting_for_approval", "waiting_for_approval", "waiting_for_approval"],
);
assert.equal(publications[2]?.state?.updatedAt, publications[0]?.state?.updatedAt);
}),
);

it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
Expand Down
Loading
Loading