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

Showing the changes to apps/server/src (4 of 4 files). Show all

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 @@
1+import { assert, it } from "@effect/vitest";
2+import {
3+ type ModelSelection,
4+ NodeId,
5+ type OrchestrationV2AppThread,
6+ ProjectId,
7+ ProviderInstanceId,
8+ ProviderThreadId,
9+ ThreadId,
10+} from "@t3tools/contracts";
11+import * as DateTime from "effect/DateTime";
12+
13+import { makeSubagentChildThread } from "./SubagentProjection.ts";
14+
15+const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent");
16+const childThreadId = ThreadId.make("thread:subagent-awake-child");
17+const parentProviderInstanceId = ProviderInstanceId.make("codex");
18+const childProviderInstanceId = ProviderInstanceId.make("claude");
19+const parentModelSelection = {
20+ instanceId: parentProviderInstanceId,
21+ model: "gpt-5.4",
22+} satisfies ModelSelection;
23+const childModelSelection = {
24+ instanceId: childProviderInstanceId,
25+ model: "claude-opus-4-1",
26+} satisfies ModelSelection;
27+const parentCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
28+const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
29+const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
30+const childCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
31+
32+function 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+
63+it("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
7070 createdAt: input.now,
7171 updatedAt: input.now,
7272 archivedAt: null,
73+ settledOverride: null,
74+ settledAt: null,
75+ snoozedUntil: null,
76+ snoozedAt: null,
7377 deletedAt: null,
7478 };
7579 }
apps/server/src/orchestration-v2/ThreadForkService.test.ts+147−0
@@ -0,0 +1,147 @@
1+import { assert, it } from "@effect/vitest";
2+import {
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";
15+import * as DateTime from "effect/DateTime";
16+import * as Effect from "effect/Effect";
17+
18+import { layer, ThreadForkServiceV2 } from "./ThreadForkService.ts";
19+
20+const sourceThreadId = ThreadId.make("thread:fork-snoozed-source");
21+const targetThreadId = ThreadId.make("thread:fork-awake-target");
22+const sourceRunId = RunId.make("run:fork-snoozed-source");
23+const providerInstanceId = ProviderInstanceId.make("codex");
24+const modelSelection = {
25+ instanceId: providerInstanceId,
26+ model: "gpt-5.4",
27+} satisfies ModelSelection;
28+const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
29+const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
30+const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
31+const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
32+
33+function 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+
64+function 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+
85+it.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
8282 createdAt: input.createdAt,
8383 updatedAt: input.createdAt,
8484 archivedAt: null,
85+ settledOverride: null,
86+ settledAt: null,
87+ snoozedUntil: null,
88+ snoozedAt: null,
8589 deletedAt: null,
8690 };
8791 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