History

fix(server): keep derived threads awake

Reset snooze metadata when creating forks and provider subagent children, with focused regressions for both inheritance paths.

Julius Marminge committed 3 months ago

Commit f6bf68c1 parent 1bb55d24 files · +254 −0Browse files

Unified · Split

4 files
  1. apps/server/src/orchestration-v2/SubagentProjection.test.ts +99 −0
  2. apps/server/src/orchestration-v2/SubagentProjection.ts +4 −0
  3. apps/server/src/orchestration-v2/ThreadForkService.test.ts +147 −0
  4. apps/server/src/orchestration-v2/ThreadForkService.ts +4 −0
apps/server/src/orchestration-v2/SubagentProjection.test.ts+99−0
@@ -0,0 +1,99 @@
1import { assert, it } from "@effect/vitest";
2import {
3 type ModelSelection,
4 NodeId,
5 type OrchestrationV2AppThread,
6 ProjectId,
7 ProviderInstanceId,
8 ProviderThreadId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12
13import { makeSubagentChildThread } from "./SubagentProjection.ts";
14
15const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent");
16const childThreadId = ThreadId.make("thread:subagent-awake-child");
17const parentProviderInstanceId = ProviderInstanceId.make("codex");
18const childProviderInstanceId = ProviderInstanceId.make("claude");
19const parentModelSelection = {
20 instanceId: parentProviderInstanceId,
21 model: "gpt-5.4",
22} satisfies ModelSelection;
23const childModelSelection = {
24 instanceId: childProviderInstanceId,
25 model: "claude-opus-4-1",
26} satisfies ModelSelection;
27const parentCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
28const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
29const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
30const childCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
31
32function makeParentThread(): OrchestrationV2AppThread {
33 return {
34 createdBy: "user",
35 creationSource: "web",
36 id: parentThreadId,
37 projectId: ProjectId.make("project:subagent-snooze"),
38 title: "Snoozed parent",
39 providerInstanceId: parentProviderInstanceId,
40 modelSelection: parentModelSelection,
41 runtimeMode: "full-access",
42 interactionMode: "plan",
43 branch: "feature/source",
44 worktreePath: "/tmp/source-worktree",
45 activeProviderThreadId: ProviderThreadId.make("provider-thread:subagent-snoozed-parent"),
46 lineage: {
47 parentThreadId: null,
48 relationshipToParent: null,
49 rootThreadId: parentThreadId,
50 },
51 forkedFrom: null,
52 createdAt: parentCreatedAt,
53 updatedAt: snoozedAt,
54 archivedAt: null,
55 settledOverride: null,
56 settledAt: null,
57 snoozedUntil,
58 snoozedAt,
59 deletedAt: null,
60 };
61}
62
63it("keeps a subagent child awake when its parent thread is snoozed", () => {
64 const parentThread = makeParentThread();
65 const childProviderThreadId = ProviderThreadId.make("provider-thread:subagent-awake-child");
66 const parentNodeId = NodeId.make("node:subagent-parent");
67 const childThread = makeSubagentChildThread({
68 parentThread,
69 childThreadId,
70 parentNodeId,
71 activeProviderThreadId: childProviderThreadId,
72 providerInstanceId: childProviderInstanceId,
73 modelSelection: childModelSelection,
74 title: "Awake child",
75 now: childCreatedAt,
76 createdBy: "agent",
77 creationSource: "provider",
78 });
79
80 assert.isNull(childThread.snoozedUntil);
81 assert.isNull(childThread.snoozedAt);
82 assert.equal(childThread.projectId, parentThread.projectId);
83 assert.equal(childThread.runtimeMode, parentThread.runtimeMode);
84 assert.equal(childThread.interactionMode, parentThread.interactionMode);
85 assert.equal(childThread.branch, parentThread.branch);
86 assert.equal(childThread.worktreePath, parentThread.worktreePath);
87 assert.equal(childThread.providerInstanceId, childProviderInstanceId);
88 assert.deepEqual(childThread.modelSelection, childModelSelection);
89 assert.equal(childThread.activeProviderThreadId, childProviderThreadId);
90 assert.deepEqual(childThread.lineage, {
91 parentThreadId,
92 relationshipToParent: "subagent",
93 rootThreadId: parentThreadId,
94 });
95 assert.deepEqual(childThread.forkedFrom, {
96 type: "node",
97 nodeId: parentNodeId,
98 });
99});
apps/server/src/orchestration-v2/SubagentProjection.ts+4−0
@@ -70,6 +70,10 @@ export function makeSubagentChildThread(input: { · 69 unmodified lines
70 createdAt: input.now,70 createdAt: input.now,
71 updatedAt: input.now,71 updatedAt: input.now,
72 archivedAt: null,72 archivedAt: null,
73 settledOverride: null,
74 settledAt: null,
75 snoozedUntil: null,
76 snoozedAt: null,
73 deletedAt: null,77 deletedAt: null,
74 };78 };
75}79}
apps/server/src/orchestration-v2/ThreadForkService.test.ts+147−0
@@ -0,0 +1,147 @@
1import { assert, it } from "@effect/vitest";
2import {
3 ContextTransferId,
4 MessageId,
5 type ModelSelection,
6 type OrchestrationV2AppThread,
7 type OrchestrationV2Run,
8 type OrchestrationV2ThreadProjection,
9 ProjectId,
10 ProviderInstanceId,
11 ProviderThreadId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17
18import { layer, ThreadForkServiceV2 } from "./ThreadForkService.ts";
19
20const sourceThreadId = ThreadId.make("thread:fork-snoozed-source");
21const targetThreadId = ThreadId.make("thread:fork-awake-target");
22const sourceRunId = RunId.make("run:fork-snoozed-source");
23const providerInstanceId = ProviderInstanceId.make("codex");
24const modelSelection = {
25 instanceId: providerInstanceId,
26 model: "gpt-5.4",
27} satisfies ModelSelection;
28const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
29const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
30const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
31const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
32
33function makeSourceThread(): OrchestrationV2AppThread {
34 return {
35 createdBy: "user",
36 creationSource: "web",
37 id: sourceThreadId,
38 projectId: ProjectId.make("project:fork-snooze"),
39 title: "Snoozed source",
40 providerInstanceId,
41 modelSelection,
42 runtimeMode: "full-access",
43 interactionMode: "plan",
44 branch: "feature/source",
45 worktreePath: "/tmp/source-worktree",
46 activeProviderThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
47 lineage: {
48 parentThreadId: null,
49 relationshipToParent: null,
50 rootThreadId: sourceThreadId,
51 },
52 forkedFrom: null,
53 createdAt: sourceCreatedAt,
54 updatedAt: snoozedAt,
55 archivedAt: null,
56 settledOverride: null,
57 settledAt: null,
58 snoozedUntil,
59 snoozedAt,
60 deletedAt: null,
61 };
62}
63
64function makeCompletedSourceRun(): OrchestrationV2Run {
65 return {
66 id: sourceRunId,
67 threadId: sourceThreadId,
68 ordinal: 1,
69 providerInstanceId,
70 modelSelection,
71 providerThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
72 userMessageId: MessageId.make("message:fork-snoozed-source"),
73 rootNodeId: null,
74 activeAttemptId: null,
75 status: "completed",
76 queuePosition: null,
77 requestedAt: sourceCreatedAt,
78 startedAt: sourceCreatedAt,
79 completedAt: snoozedAt,
80 checkpointId: null,
81 contextHandoffId: null,
82 };
83}
84
85it.effect("keeps a fork awake when its source thread is snoozed", () =>
86 Effect.gen(function* () {
87 const sourceThread = makeSourceThread();
88 const sourceRun = makeCompletedSourceRun();
89 const sourceProjection: OrchestrationV2ThreadProjection = {
90 thread: sourceThread,
91 runs: [sourceRun],
92 attempts: [],
93 nodes: [],
94 subagents: [],
95 providerSessions: [],
96 providerThreads: [],
97 providerTurns: [],
98 runtimeRequests: [],
99 messages: [],
100 plans: [],
101 turnItems: [],
102 checkpointScopes: [],
103 checkpoints: [],
104 contextHandoffs: [],
105 contextTransfers: [],
106 visibleTurnItems: [],
107 updatedAt: snoozedAt,
108 };
109 const service = yield* ThreadForkServiceV2;
110 const result = yield* service.plan({
111 sourceProjection,
112 sourceRun,
113 sourceProviderThread: undefined,
114 canonicalSourcePoint: {
115 threadId: sourceThreadId,
116 runId: sourceRunId,
117 },
118 transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"),
119 targetThreadId,
120 title: "Awake fork",
121 createdBy: "user",
122 creationSource: "mobile",
123 createdAt: forkCreatedAt,
124 });
125
126 assert.isNull(result.targetThread.snoozedUntil);
127 assert.isNull(result.targetThread.snoozedAt);
128 assert.equal(result.targetThread.projectId, sourceThread.projectId);
129 assert.equal(result.targetThread.providerInstanceId, sourceThread.providerInstanceId);
130 assert.deepEqual(result.targetThread.modelSelection, sourceThread.modelSelection);
131 assert.equal(result.targetThread.runtimeMode, sourceThread.runtimeMode);
132 assert.equal(result.targetThread.interactionMode, sourceThread.interactionMode);
133 assert.equal(result.targetThread.branch, sourceThread.branch);
134 assert.equal(result.targetThread.worktreePath, sourceThread.worktreePath);
135 assert.isNull(result.targetThread.activeProviderThreadId);
136 assert.deepEqual(result.targetThread.lineage, {
137 parentThreadId: sourceThreadId,
138 relationshipToParent: "fork",
139 rootThreadId: sourceThreadId,
140 });
141 assert.deepEqual(result.targetThread.forkedFrom, {
142 type: "run",
143 threadId: sourceThreadId,
144 runId: sourceRunId,
145 });
146 }).pipe(Effect.provide(layer)),
147);
apps/server/src/orchestration-v2/ThreadForkService.ts+4−0
@@ -82,6 +82,10 @@ export const layer: Layer.Layer<ThreadForkServiceV2> = Layer.succeed( · 81 unmodified lines
82 createdAt: input.createdAt,82 createdAt: input.createdAt,
83 updatedAt: input.createdAt,83 updatedAt: input.createdAt,
84 archivedAt: null,84 archivedAt: null,
85 settledOverride: null,
86 settledAt: null,
87 snoozedUntil: null,
88 snoozedAt: null,
85 deletedAt: null,89 deletedAt: null,
86 };90 };
87 const transfer: OrchestrationV2ContextTransfer = {91 const transfer: OrchestrationV2ContextTransfer = {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t