authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 22:25:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 22:25:00-07:00
logddcd3102827f520e3dff53b902111744c0ba2ced
tree6558d617b96947fabc5be0e326d841bd1de594fb
parentc5a929e1ac05dec15945a36905ab8bb2f1982e59
signature Signed by PGP key B5690EEEBB952194

chore: docs, dev scripts and CI catch up with orchestration V2 (#15041)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

29 files changed, 305 insertions(+), 417 deletions(-)

.agents/skills/test-t3-app/references/sqlite-fixtures.md+7-16
......@@ -4,7 +4,9 @@ Load this reference only when inspecting or seeding local T3 state directly.
44
55## Select the correct database
66
7When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata` and the database path is `<base-dir>/userdata/state.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.
7When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata` and the database path is `<base-dir>/userdata/statev2.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.
8
9The server copies the V1 `state.sqlite` into `statev2.sqlite` only when `statev2.sqlite` is missing. After the first start, edits to `state.sqlite` change nothing.
810
911Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection.
1012
......@@ -23,7 +25,7 @@ Inspect current columns before writing a fixture:
2325```bash
2426node apps/server/scripts/t3-sqlite-state.ts query \
2527 --base-dir <base-dir> \
26 --sql "PRAGMA table_info(projection_threads)"
28 --sql "PRAGMA table_info(orchestration_v2_projection_threads)"
2729```
2830
2931Apply a SQL fixture from a file:
......@@ -38,21 +40,10 @@ Use one statement per invocation for both `query` and `exec`; the helper wraps w
3840
3941## Seed projection data carefully
4042
41The web UI primarily reads these projection tables:
42
43- `projection_projects`
44- `projection_threads`
45- `projection_thread_messages`
46- `projection_thread_activities`
47- `projection_thread_sessions`
48- `projection_turns`
49- `projection_pending_approvals`
50- `projection_thread_proposed_plans`
51
52Inspect `PRAGMA table_info(<table>)` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent.
43Clients read projects from `projection_projects` and everything else from the `orchestration_v2_projection_*` tables: threads, runs, messages, turn items, runtime requests, plans, and provider sessions. The older `projection_thread*` tables hold V1 history, which the server imports once per thread at startup; later edits there do not reach the UI.
5344
54For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current.
45Most V2 rows carry a `payload_json` that must decode against the schemas in `packages/contracts/src/orchestrationV2.ts`. The safest start is a row the app wrote itself: create a thread through the UI, copy its rows, and edit them. Keep identifiers unique, timestamps as ISO strings, and related project, thread, and run IDs consistent.
5546
56Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works.
47Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, long timelines, and similar UI fixtures. They do not create a coherent event history. Leave the event log (`orchestration_events`) unchanged, and do not use direct projection writes to claim backend business behavior works.
5748
5849Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`.
.github/workflows/ci.yml+1
......@@ -487,6 +487,7 @@ jobs:
487487 test,
488488 test_web,
489489 test_server,
490 transfer-report,
490491 rust,
491492 mobile_native_changes,
492493 mobile_native_static_analysis,
AGENTS.md+6-15
......@@ -88,17 +88,8 @@ The most common defect in this repo is a change that works on the path you teste
8888
8989An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state:
9090
91- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `<worktree>/.t3/userdata`.
92- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file:
93
94 ```bash
95 mkdir -p .t3/userdata
96 rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite
97 bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")"
98 ```
99
100 A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy.
101
91- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `<worktree>/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data.
92- Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing.
10293- Bring `secrets` and `settings.json` only if the flow under test needs them.
10394- Copy in, never symlink. Data flows one way: into your sandbox, never back out.
10495
......@@ -108,7 +99,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
10899- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation.
109100- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
110101- Backend behavior changes ship with focused tests for that behavior.
111- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong.
102- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong.
112103- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers.
113104
114105For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure <ios|android> <device-id>` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow.
......@@ -120,7 +111,7 @@ For authorized mobile verification, a missing or outdated native client is a bui
120111- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work.
121112- UI changes need before/after images. Motion or timing needs a short video.
122113- Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`.
123- One concern per PR. If the description says "also", split it.
114- One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule.
124115- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit.
125116
126117## Documentation
......@@ -143,9 +134,9 @@ Most code changes do not need an internal documentation change. Agents can read
143134
144135## How it works
145136
146Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore.
137Clients send typed WebSocket requests. The server turns them into _commands_. The _orchestrator_ (`apps/server/src/orchestration-v2/Orchestrator.ts`) serializes commands and decides _events_ without doing any I/O. The _event sink_ commits those events, the _projections_ the UI reads, the _command receipt_, and _outbox_ effects in one transaction. The _effect worker_ then runs the effects, such as starting a provider turn or capturing a checkpoint, and feeds results back as commands. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore.
147138
148Full glossary with file links: `docs/internals/glossary.md`
139Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/internals/glossary.md`
149140
150141## Where code lives
151142
CONTRIBUTING.md+3
......@@ -70,6 +70,9 @@ Explain their relationship when it is not obvious. An adjacent cleanup, refactor
7070its own PR unless it is necessary to solve the same problem. A large diff alone does not establish that
7171the PR contains unrelated work.
7272
73This rule is for outside contributions. Maintainers, the logins in
74[.github/TRIAGE_EXEMPTIONS.td](.github/TRIAGE_EXEMPTIONS.td), may batch related fixes in one PR.
75
7376Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and
7477hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions
7578of obvious controls and cosmetic changes.
apps/desktop/src/backend/DesktopBackendConfiguration.ts+1-1
......@@ -644,7 +644,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
644644 // The packaged sidecar is a Windows executable and cannot run inside the
645645 // Linux WSL backend. Keep the field absent instead of passing an unusable
646646 // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable.
647 // See docs/architecture/resource-telemetry.md.
647 // See docs/internals/resource-telemetry.md.
648648 ...buildObservabilityFragment(input.observabilitySettings),
649649 };
650650
apps/server/scripts/migrate-dev-db.test.ts+66-27
......@@ -14,24 +14,21 @@ const withDatabase = <A, E>(
1414 effect: Effect.Effect<A, E, SqlClient.SqlClient>,
1515) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath })));
1616
17/** A migrated source db with one thread per lifecycle state. Only
18 * `stopped-thread` qualifies for the clone. */
17/** A migrated source db with one V2 thread per lifecycle state. Only
18 * `stopped-thread` and its fork qualify for the clone. */
1919const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* (
2020 baseDir: string,
2121) {
2222 const fs = yield* FileSystem.FileSystem;
2323 const path = yield* Path.Path;
2424 const stateDir = path.join(baseDir, "userdata");
25 const databasePath = path.join(stateDir, "state.sqlite");
25 const databasePath = path.join(stateDir, "statev2.sqlite");
2626 yield* fs.makeDirectory(stateDir, { recursive: true });
2727 yield* withDatabase(
2828 databasePath,
2929 Effect.gen(function* () {
3030 const sql = yield* SqlClient.SqlClient;
3131 yield* runMigrations();
32 // The real shared db carries this column from a branch build without a
33 // matching migration; reproduce that drift so the filter is exercised.
34 yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`;
3532
3633 yield* sql`INSERT INTO projection_projects
3734 (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at)
......@@ -39,23 +36,56 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio
3936 ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL),
4037 ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`;
4138
39 const forkPayload =
40 '{"lineage":{"parentThreadId":"stopped-thread","relationshipToParent":"fork","rootThreadId":"stopped-thread"}}';
41 const subagentPayload =
42 '{"lineage":{"parentThreadId":"subagent-parent","relationshipToParent":"subagent","rootThreadId":"subagent-parent"},"forkedFrom":{"type":"node","nodeId":"node-1"}}';
43 // Excluded threads are newer than the kept family, so only the filters
44 // can keep them out of a one-family-per-project clone.
4245 const threads = [
43 ["stopped-thread", "project-kept", "stopped", null, null],
44 ["running-thread", "project-kept", "running", null, null],
45 ["settled-thread", "project-kept", "stopped", "2026-08-01", null],
46 ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'],
47 ["deleted-project-thread", "project-deleted", "stopped", null, null],
46 ["stopped-thread", "project-kept", "completed", "{}", "2026-08-01"],
47 ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"],
48 ["running-thread", "project-kept", "running", "{}", "2026-08-05"],
49 ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"],
50 [
51 "limit-thread",
52 "project-kept",
53 "completed",
54 '{"limitRecovery":{"autoResume":true}}',
55 "2026-08-05",
56 ],
57 // Its result never reached the parent, so startup would deliver it.
58 ["subagent-parent", "project-kept", "completed", "{}", "2026-08-05"],
59 ["subagent-child", "project-kept", "completed", subagentPayload, "2026-08-05"],
60 ["deleted-project-thread", "project-deleted", "completed", "{}", "2026-08-05"],
4861 ] as const;
49 for (const [threadId, projectId, status, settledAt, monitorJson] of threads) {
50 yield* sql`INSERT INTO projection_threads
51 (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json)
52 VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`;
53 yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at)
54 VALUES (${threadId}, ${status}, '2026-08-01')`;
62 for (const [threadId, projectId, runStatus, payload, updatedAt] of threads) {
63 yield* sql`INSERT INTO orchestration_v2_projection_threads
64 (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json)
65 VALUES (${threadId}, ${projectId}, ${threadId}, 'codex', 'full-access', 'default', '2026-08-01', ${updatedAt}, ${payload})`;
66 yield* sql`INSERT INTO orchestration_v2_projection_runs
67 (run_id, thread_id, ordinal, provider, status, requested_at, payload_json)
68 VALUES (${`run-${threadId}`}, ${threadId}, 1, 'codex', ${runStatus}, '2026-08-01', '{}')`;
5569 yield* sql`INSERT INTO orchestration_events
5670 (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json)
5771 VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`;
5872 }
73 // A provider session shared by two threads names its latest writer.
74 yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions
75 (provider_session_id, thread_id, provider, status, updated_at, payload_json)
76 VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`;
77 yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings
78 (provider_session_id, thread_id)
79 VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`;
80 yield* sql`INSERT INTO orchestration_v2_projection_context_transfers
81 (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json)
82 VALUES ('transfer-1', 'settled-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`;
83 yield* sql`INSERT INTO scheduled_tasks
84 (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json,
85 model_selection_json, runtime_mode, interaction_mode, created_by, creation_source,
86 created_at, updated_at, last_run_status, run_count)
87 VALUES ('task-1', 'Nightly', 'Run it', 1, '{}', 'project-kept', '{}', '{}',
88 'full-access', 'default', 'user', 'user', '2026-08-01', '2026-08-01', 'never', 0)`;
5989 yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at)
6090 VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`;
6191 }),
......@@ -64,7 +94,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio
6494});
6595
6696it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
67 it.effect("keeps only stopped threads from live projects and clears auth state", () =>
97 it.effect("keeps stopped thread families from live projects and clears pending work", () =>
6898 Effect.gen(function* () {
6999 const fs = yield* FileSystem.FileSystem;
70100 const path = yield* Path.Path;
......@@ -73,7 +103,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
73103 const source = yield* createFixtureSource(sourceDir);
74104
75105 const result = yield* runMigrateDevDb(
76 { baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
106 { baseDir: destDir, source, projects: 5, threadsPerProject: 1 },
77107 { sharedHome: sourceDir },
78108 );
79109
......@@ -83,23 +113,32 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
83113 Effect.gen(function* () {
84114 const sql = yield* SqlClient.SqlClient;
85115 const threads = yield* sql<{ thread_id: string }>`
86 SELECT thread_id FROM projection_threads ORDER BY thread_id`;
116 SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`;
87117 const events = yield* sql<{ stream_id: string }>`
88 SELECT stream_id FROM orchestration_events`;
89 const [auth] = yield* sql<{ count: number }>`
90 SELECT COUNT(*) AS count FROM auth_sessions`;
91 return { threads, events, authCount: auth?.count ?? 0 };
118 SELECT stream_id FROM orchestration_events ORDER BY stream_id`;
119 const sessions = yield* sql<{ provider_session_id: string }>`
120 SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`;
121 const [leftovers] = yield* sql<{ auth: number; tasks: number; transfers: number }>`
122 SELECT
123 (SELECT COUNT(*) FROM auth_sessions) AS auth,
124 (SELECT COUNT(*) FROM scheduled_tasks) AS tasks,
125 (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers`;
126 return { threads, events, sessions, leftovers };
92127 }),
93128 );
94129 assert.deepStrictEqual(
95130 kept.threads.map((row) => row.thread_id),
96 ["stopped-thread"],
131 ["fork-thread", "stopped-thread"],
97132 );
98133 assert.deepStrictEqual(
99134 kept.events.map((row) => row.stream_id),
100 ["stopped-thread"],
135 ["fork-thread", "stopped-thread"],
136 );
137 assert.deepStrictEqual(
138 kept.sessions.map((row) => row.provider_session_id),
139 ["session-shared"],
101140 );
102 assert.equal(kept.authCount, 0);
141 assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, transfers: 0 });
103142 }),
104143 );
105144
apps/server/scripts/migrate-dev-db.ts+108-59
......@@ -6,10 +6,12 @@
66 *
77 * `vp run migrate-dev-db` from a worktree:
88 * 1. Nukes `<worktree>/.t3/userdata/statev2.sqlite`.
9 * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the
10 * most recently updated projects and, per project, the most recent
11 * threads that have fully stopped. Working, settled, and monitored
12 * threads are skipped so the dev server never adopts live work.
9 * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only
10 * VACUUM INTO) and prunes it to the most recently updated projects and,
11 * per project, the most recent threads that have fully stopped, with
12 * their forks and subagents. Working, settled, and archived threads, and
13 * threads with pending recovery, are skipped, and scheduled tasks and
14 * queued effects are dropped, so the dev server never adopts live work.
1315 * Auth sessions, pairing links, command receipts, and provider
1416 * runtime rows are dropped — pair a fresh browser against dev.
1517 * 3. Runs migrations on the result. Because the clone carries the real
......@@ -19,9 +21,8 @@
1921 * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped).
2022 *
2123 * The event log (`orchestration_events`) is pruned per stream while
22 * `sqlite_sequence` and `projection_state` carry over untouched, so new
23 * events keep appending after the old high-water mark and projection
24 * cursors never rewind.
24 * `sqlite_sequence` and the projection cursors carry over untouched, so new events keep appending
25 * after the old high-water mark and projection cursors never rewind.
2526 */
2627
2728import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
......@@ -31,12 +32,14 @@ import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
3132import * as Console from "effect/Console";
3233import * as Effect from "effect/Effect";
3334import * as FileSystem from "effect/FileSystem";
35import * as Layer from "effect/Layer";
3436import * as Option from "effect/Option";
3537import * as Path from "effect/Path";
3638import * as Schema from "effect/Schema";
3739import * as SqlClient from "effect/unstable/sql/SqlClient";
3840import { Command, Flag } from "effect/unstable/cli";
3941
42import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts";
4043import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts";
4144import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4245
......@@ -143,7 +146,7 @@ export class MigrateDevDbPhaseError extends Schema.TaggedError<MigrateDevDbPhase
143146export interface RunMigrateDevDbInput {
144147 /** Isolated .t3 directory. Defaults to `<worktree>/.t3` of the cwd. */
145148 readonly baseDir?: string | undefined;
146 /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */
149 /** Source database. Defaults to `~/.t3/userdata/statev2.sqlite`. */
147150 readonly source?: string | undefined;
148151 readonly projects: number;
149152 readonly threadsPerProject: number;
......@@ -232,31 +235,56 @@ const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath:
232235 }
233236});
234237
238const RECOVERY_KINDS: ReadonlyArray<ProjectionStore.ProjectionRecoveryKind> = [
239 "queued-runs",
240 "runtime",
241 "subagent-results",
242 "delegated-completions",
243];
244
235245const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) {
236246 const sql = yield* SqlClient.SqlClient;
237247
238 // The shared db can carry monitor_json from a branch build even though no
239 // migration in this checkout creates it, so filter it only when present.
240 const threadColumns = yield* sql<{ name: string }>`
241 SELECT name FROM pragma_table_info('projection_threads')`;
242 const monitorFilter = threadColumns.some((column) => column.name === "monitor_json")
243 ? "AND t.monitor_json IS NULL"
244 : "";
245
246 // "Stopped" is the persisted subset of the UI's thread status: the session
247 // reached status 'stopped' and nothing marks the thread settled or
248 // monitored. The in-memory working/monitoring liveness never persists, so
249 // filtering the session status is sufficient.
250 yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS
251 SELECT t.thread_id, t.project_id, t.updated_at
252 FROM projection_threads t
253 JOIN projection_thread_sessions s ON s.thread_id = t.thread_id
254 WHERE t.deleted_at IS NULL
255 AND t.archived_at IS NULL
256 AND t.settled_at IS NULL
257 AND (t.settled_override IS NULL OR t.settled_override <> 'settled')
258 ${monitorFilter}
259 AND s.status = 'stopped'`).unprepared;
248 // Threads the server would resume or start work on by itself. Its own
249 // recovery queries find most of them; usage-limit recovery also depends on
250 // settings, so any thread whose latest run failed or that has a recovery
251 // choice counts as live too.
252 const projections = yield* ProjectionStore.ProjectionStoreV2;
253 const recoveryThreadIds = yield* Effect.forEach(RECOVERY_KINDS, (kind) =>
254 projections.getRecoveryThreadIds(kind),
255 );
256 yield* sql`CREATE TEMP TABLE live_threads (thread_id TEXT PRIMARY KEY)`;
257 for (const threadId of new Set(recoveryThreadIds.flat())) {
258 yield* sql`INSERT INTO live_threads (thread_id) VALUES (${threadId})`;
259 }
260 yield* sql`INSERT OR IGNORE INTO live_threads (thread_id)
261 SELECT r.thread_id FROM orchestration_v2_projection_runs r
262 WHERE r.status = 'failed' AND r.ordinal = (
263 SELECT MAX(latest.ordinal) FROM orchestration_v2_projection_runs latest
264 WHERE latest.thread_id = r.thread_id)
265 UNION
266 SELECT thread_id FROM orchestration_v2_projection_threads
267 WHERE json_extract(payload_json, '$.limitRecovery') IS NOT NULL`;
268
269 // Forks and subagents read history and results through their lineage, so
270 // a thread family is cloned or dropped as a whole. A family is stopped when
271 // its root is visible and unsettled and none of its threads is live.
272 yield* sql`CREATE TEMP TABLE thread_families AS
273 SELECT thread_id, updated_at,
274 COALESCE(json_extract(payload_json, '$.lineage.rootThreadId'), thread_id) AS root_id
275 FROM orchestration_v2_projection_threads`;
276
277 yield* sql`CREATE TEMP TABLE stopped_families AS
278 SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at
279 FROM thread_families f
280 JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id
281 WHERE root.deleted_at IS NULL
282 AND json_extract(root.payload_json, '$.deletedAt') IS NULL
283 AND json_extract(root.payload_json, '$.archivedAt') IS NULL
284 AND json_extract(root.payload_json, '$.settledAt') IS NULL
285 AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'
286 GROUP BY f.root_id, root.project_id
287 HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`;
260288
261289 // Projects with clonable threads outrank empty-but-recent ones: the point
262290 // of the exercise is thread data, not the project list.
......@@ -265,7 +293,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
265293 FROM projection_projects p
266294 LEFT JOIN (
267295 SELECT project_id, MAX(updated_at) AS last_stopped_at
268 FROM stopped_threads
296 FROM stopped_families
269297 GROUP BY project_id
270298 ) q ON q.project_id = p.project_id
271299 WHERE p.deleted_at IS NULL
......@@ -274,42 +302,56 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
274302 LIMIT ${input.projects}`;
275303
276304 yield* sql`CREATE TEMP TABLE kept_threads AS
277 SELECT thread_id FROM (
278 SELECT
279 st.thread_id,
280 ROW_NUMBER() OVER (
281 PARTITION BY st.project_id
282 ORDER BY st.updated_at DESC
283 ) AS recency_rank
284 FROM stopped_threads st
285 JOIN kept_projects kp ON kp.project_id = st.project_id
286 )
287 WHERE recency_rank <= ${input.threadsPerProject}`;
305 SELECT f.thread_id FROM thread_families f
306 WHERE f.root_id IN (
307 SELECT root_id FROM (
308 SELECT
309 sf.root_id,
310 ROW_NUMBER() OVER (
311 PARTITION BY sf.project_id
312 ORDER BY sf.updated_at DESC
313 ) AS recency_rank
314 FROM stopped_families sf
315 JOIN kept_projects kp ON kp.project_id = sf.project_id
316 )
317 WHERE recency_rank <= ${input.threadsPerProject}
318 )`;
319
320 // Every V2 table and every V1 table the lazy importer reads is keyed by
321 // thread_id, so one sweep covers both and new tables need no change here.
322 // Provider sessions are shared between threads and pruned by binding below.
323 const threadTables = yield* sql<{ name: string }>`
324 SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c
325 WHERE m.type = 'table' AND c.name = 'thread_id'
326 AND m.name <> 'orchestration_v2_projection_provider_sessions'`;
288327
289328 yield* sql.withTransaction(
290329 Effect.gen(function* () {
291330 yield* sql`DELETE FROM projection_projects
292331 WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`;
293 yield* sql`DELETE FROM projection_threads
294 WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`;
295 for (const table of [
296 "projection_thread_messages",
297 "projection_thread_activities",
298 "projection_thread_sessions",
299 "projection_turns",
300 "projection_pending_approvals",
301 "projection_thread_proposed_plans",
302 "checkpoint_diff_blobs",
303 ]) {
332 yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions
333 WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads)
334 AND provider_session_id NOT IN (
335 SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings
336 WHERE thread_id IN (SELECT thread_id FROM kept_threads)
337 )`;
338 for (const { name } of threadTables) {
304339 yield* sql.unsafe(
305 `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`,
340 `DELETE FROM "${name}" WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`,
306341 ).unprepared;
307342 }
343 yield* sql`DELETE FROM orchestration_v2_projection_context_transfers
344 WHERE source_thread_id NOT IN (SELECT thread_id FROM kept_threads)
345 OR target_thread_id NOT IN (SELECT thread_id FROM kept_threads)`;
308346 yield* sql`DELETE FROM orchestration_events
309347 WHERE (aggregate_kind = 'thread'
310348 AND stream_id NOT IN (SELECT thread_id FROM kept_threads))
311349 OR (aggregate_kind = 'project'
312350 AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;
351 // Pending work the dev server would otherwise pick up and run.
352 yield* sql`DELETE FROM scheduled_tasks`;
353 yield* sql`DELETE FROM orchestration_v2_effect_outbox`;
354 yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`;
313355 yield* sql`DELETE FROM orchestration_command_receipts`;
314356 yield* sql`DELETE FROM provider_session_runtime`;
315357 yield* sql`DELETE FROM auth_sessions`;
......@@ -320,7 +362,8 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
320362 const keptProjects = yield* sql<{ title: string; threads: number }>`
321363 SELECT
322364 p.title,
323 (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads
365 (SELECT COUNT(*) FROM orchestration_v2_projection_threads t
366 WHERE t.project_id = p.project_id) AS threads
324367 FROM projection_projects p
325368 ORDER BY p.updated_at DESC`;
326369 const [events] = yield* sql<{ count: number }>`
......@@ -362,7 +405,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
362405
363406 const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
364407 const sourcePath = path.resolve(
365 input.source ?? path.join(sharedHome, "userdata", "state.sqlite"),
408 input.source ?? path.join(sharedHome, "userdata", "statev2.sqlite"),
366409 );
367410
368411 const baseDir =
......@@ -458,7 +501,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
458501 `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`,
459502 );
460503 const result = yield* pruneSnapshot(input).pipe(
461 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
504 Effect.provide(
505 ProjectionStore.layer.pipe(
506 Layer.provideMerge(NodeSqliteClient.layer({ filename: snapshotPath })),
507 ),
508 ),
462509 wrapPhase("prune", snapshotPath),
463510 );
464511
......@@ -512,7 +559,9 @@ export const migrateDevDbCommand = Command.make(
512559 ),
513560 threadsPerProject: Flag.Int("threads-per-project").pipe(
514561 Flag.withDefault(10),
515 Flag.withDescription("How many recent stopped threads to keep per project."),
562 Flag.withDescription(
563 "How many recent stopped threads, with their forks and subagents, to keep per project.",
564 ),
516565 ),
517566 baseDir: Flag.String("base-dir").pipe(
518567 Flag.optional,
......@@ -520,7 +569,7 @@ export const migrateDevDbCommand = Command.make(
520569 ),
521570 source: Flag.String("source").pipe(
522571 Flag.optional,
523 Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."),
572 Flag.withDescription("Source database. Defaults to ~/.t3/userdata/statev2.sqlite."),
524573 ),
525574 },
526575 ({ projects, threadsPerProject, baseDir, source }) =>
apps/server/src/orchestration-v2/Orchestrator.ts+1-26
......@@ -8029,32 +8029,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
80298029 }),
80308030 );
80318031
8032 /*
8033 * TODO(interrupt-hardening): before shipping, make these interrupt
8034 * semantics explicit in tests and policy.
8035 *
8036 * Current behavior:
8037 * - emit a `run_interrupt_request` item as user intent;
8038 * - call the provider interrupt RPC;
8039 * - keep the run active and continue ingesting provider chunks;
8040 * - let RunExecutionService emit `run_interrupt_result` only if the
8041 * provider later reports terminal status `interrupted`.
8042 *
8043 * Known scenarios we do not fully harden yet:
8044 * - provider accepts interrupt, then emits more chunks before terminal;
8045 * - provider accepts interrupt, then completes normally instead;
8046 * - provider accepts interrupt but never terminalizes;
8047 * - user queues, steers, or starts another message while the interrupted
8048 * provider turn is still active.
8049 *
8050 * Likely policy:
8051 * - queue should wait behind the still-active provider turn;
8052 * - explicit steer may target the active turn if provider steering is
8053 * supported;
8054 * - starting a new root turn before provider terminalization should be
8055 * an explicit policy decision because it can weaken native-item
8056 * correlation.
8057 */
8032 // Open interrupt edge cases are tracked in https://github.com/pingdotgg/t3code/issues/15013.
80588033 yield* emitEvent({
80598034 type: "turn-item.updated",
80608035 threadId: command.threadId,
apps/server/src/orchestration-v2/TODO.md deleted-172
......@@ -1,172 +0,0 @@
1# Orchestration V2 TODO
2
3This file tracks remaining backend-oriented V2 work. Architecture-level intent lives in
4[`docs/orchestration-v2`](../../../../docs/orchestration-v2); this file is the local
5implementation checklist for `apps/server/src/orchestration-v2`.
6
7## Current Baseline
8
9- V2 commands/events/projections are server-owned and replayable.
10- Message dispatch supports start, steer, queue, queue reorder, promote queued message to steer,
11 interrupt, and provider switch command shapes.
12- Checkpoint rollback is currently a full revert: filesystem checkpoint restore, provider thread
13 rollback, stale checkpoint marking, and later run/node `rolled_back` projection state.
14- Codex same-provider fork is lazy: `thread.fork` records lineage and pending transfer, and first
15 dispatch resolves native Codex fork. Earlier source-point forks pass the source turn's native id
16 to `thread/fork` as `lastTurnId`; the fork-then-rollback fallback remains only for source turns
17 without a native turn reference and only on legacy-history threads.
18- Codex provider conversation rollback supports only legacy-history threads. The adapter probes
19 `historyMode` and fails explicitly on paginated threads. Closing the gap means paging
20 `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`.
21- Native Codex fork-from-earlier-run has a real replay-backed test fixture:
22 `testkit/fixtures/thread_fork_native_prior_turn`.
23- Merge-back from a fork into its source thread records a `merge_back` context transfer, materializes
24 a `fork_delta_summary` context handoff, and injects that handoff into the next source-thread run.
25
26## Projection Hardening
27
28Target docs:
29
30- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md)
31- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md)
32- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md)
33
34TODO:
35
36- [x] Add end-to-end projection assertions for forked threads, not only provider-context behavior.
37 The fork projection should show inherited user-visible items through the source point plus an
38 explicit fork marker.
39- [x] Decide and implement the projection representation for inherited fork history:
40 referenced lineage overlay vs physically duplicated projection items. Prefer referenced overlay
41 unless product requirements need independent editable history.
42- [x] Add local visible projection assertions to non-fork replay fixtures:
43 `visibleTurnItems` mirrors canonical local `turnItems` for simple, multi-turn, queue,
44 steering, interrupt, rollback, planning, tool, and web-search fixtures.
45- [ ] Ensure projections render rollback state consistently:
46 rolled-back runs/nodes/items, stale checkpoints, and active provider thread cursor.
47- [ ] Add projection tests for interrupt edge cases:
48 provider emits chunks after interrupt requested, provider ignores/delays interrupt, interrupt is
49 immediately followed by queue/steer/start.
50- [ ] Add projection tests for queue/steer flows:
51 queued message visibility, queue reorder, promote queued message to steer, and post-interrupt
52 dispatch visibility.
53
54## Context Transfer And Merge-Back
55
56Target docs:
57
58- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md)
59- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md)
60
61TODO:
62
63- [x] Implement merge-back from a fork into its source thread:
64 source fork point as `basePoint`, fork latest stable point as `sourcePoint`, and source-thread
65 next user message as the consuming run.
66- [x] Materialize delta context artifacts for merge-back and persist them as auditable
67 `ContextHandoff` records.
68- [x] Add replay-backed integration coverage for merge-back:
69 fork, explore in fork, merge back, then assert source provider receives only the fork delta plus
70 the new user message.
71- [ ] Implement portable context handoff for cross-provider forks and same-thread provider switches.
72- [ ] Define explicit failure states for unresolved context transfers:
73 missing source point, unsupported provider capability, context too large, source projection not
74 stable, and adapter resolution failure.
75
76## Capability And Policy Model
77
78Target docs:
79
80- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md)
81- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md)
82
83TODO:
84
85- [ ] Audit capability use against the documented nested `OrchestrationV2ProviderCapabilities`
86 shape and fill any behavior gaps.
87- [ ] Keep orchestration decisions capability/policy driven. Shared runtime code should not branch on
88 provider name except at adapter registration/resolution boundaries.
89- [ ] Make optional adapter methods impossible to call without going through a policy wrapper or a
90 capability-checked branch.
91- [ ] Add typed capability/policy errors for:
92 native fork unavailable, rollback unavailable, steering unavailable, interrupt unavailable,
93 context handoff unavailable, and weak terminal status.
94- [ ] Add capability-aware tests using real adapter/test layers at provider boundaries only. Do not mock
95 core orchestration policy.
96
97## Checkpoint And Rollback
98
99Target docs:
100
101- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md)
102- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md)
103
104TODO:
105
106- [ ] Document current `checkpoint.rollback` command semantics in contracts/docs as full revert:
107 filesystem restore plus provider conversation rollback.
108- [ ] Decide whether we need separate commands for conversation-only rollback and filesystem-only
109 restore. Do not add them until a real product flow needs them.
110- [ ] Add tests for rollback after fork and rollback inside fork:
111 source rollback should not corrupt child lineage, and fork rollback should not mutate source
112 provider state.
113- [ ] Validate rollback behavior when no active provider thread exists. Current behavior fails; decide
114 whether a filesystem-only rollback fallback is useful or too surprising.
115
116## Provider Switching And Second Adapter
117
118Target docs:
119
120- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md)
121- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md)
122- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md)
123
124TODO:
125
126- [x] Add Claude replay fixture definitions for recorded fixtures, with TODO fixture slots that
127 reference the corresponding Codex transcripts and V2 docs.
128- [ ] Promote Claude `simple` from the replay adapter to a real `ClaudeAdapterV2` replay test:
129 live and replay both consume an injected Agent SDK `query()` async iterable.
130- [ ] Record Claude `multi_turn` from real usage and prove native session/thread continuation.
131- [ ] Record Claude `tool_call_read_only` from real usage and prove read-only tool projection without
132 approvals.
133- [ ] Keep unrecorded Claude fixtures out of `testkit/fixtures/index.ts` until each has a real
134 transcript and real adapter assertions.
135- [ ] Add the second adapter once these vv0 Claude slices are stable enough to validate
136 cross-provider behavior.
137- [ ] Use the second adapter to test:
138 cross-provider fork, same-thread provider switch, returning to a previous provider thread with
139 delta handoff, and unsupported capability fallback paths.
140
141## Subagents
142
143Target docs:
144
145- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md)
146- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md)
147
148TODO:
149
150- [x] Add provider-native subagent observation for Codex and Claude replay-backed fixtures.
151- [ ] Model native subagents and app-owned cross-provider subagents as related thread/subthread graph
152 entries with different creator/lifecycle policy.
153- [ ] Preserve native provider subagent refs where available, but do not make the app graph depend on
154 provider-native ids as primary ids.
155- [ ] Add deeper tests for subagent wait, close, result transfer, pending approvals, failed/stopped
156 tasks, and fork-from-subagent behavior.
157
158FOOD FOR THOUGHT:
159
160- Custom t3code tools/mcp_server that lets agents spawn subagents of other providers powered by the T3 Orchestrator
161
162## Debugger-Only Work
163
164- Keep debugger UI useful but temporary. Backend semantics and projections should be the source of
165 truth.
166- [x] Wire the debugger thread tree to persisted V2 shell state through websocket RPC instead of
167 debugger-local thread discovery state.
168- Continue exposing lightweight controls for new backend surfaces:
169 fork from response, new thread, full revert from user message checkpoint, merge-back, and provider
170 switch.
171- Avoid adding mock backend behavior for debugger convenience. In-memory debugger state is fine for
172 layout affordances, but backend behavior must route through V2 commands/projections.
apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Index setup composed by migration 050.
4// Index setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts+1-1
......@@ -30,7 +30,7 @@ interface ProjectProjectionRow {
3030const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
3131const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
3232
33// Event-store setup and V1 project baseline composed by migration 050.
33// Event-store setup and V1 project baseline composed by migration 055.
3434export default Effect.gen(function* () {
3535 const sql = yield* SqlClient.SqlClient;
3636
apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Schema setup composed by migration 050.
4// Schema setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts+1-1
......@@ -2,7 +2,7 @@ import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
44/**
5 * Production-facing V2 persistence setup composed by migration 050.
5 * Production-facing V2 persistence setup composed by migration 055.
66 *
77 * The original V2 schema used a single `provider` column for both configured
88 * instance routing and driver identity. Keep those columns in place for
apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts+1-1
......@@ -5,7 +5,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
55 * Tracks the incremental import of v1 materialized thread state into the v2
66 * event model. Shells are imported synchronously at startup; full transcripts
77 * are hydrated on demand and by a low-priority background pass. This table
8 * setup is composed by migration 050.
8 * setup is composed by migration 055.
99 */
1010export default Effect.gen(function* () {
1111 const sql = yield* SqlClient.SqlClient;
apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Schema setup composed by migration 050.
4// Schema setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Index setup composed by migration 050.
4// Index setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Schema setup composed by migration 050.
4// Schema setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Index setup composed by migration 050.
4// Index setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Schema setup composed by migration 050.
4// Schema setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77
apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts+1-1
......@@ -1,7 +1,7 @@
11import * as Effect from "effect/Effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4// Schema setup composed by migration 050.
4// Schema setup composed by migration 055.
55export default Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
77 yield* sql`
apps/server/src/storageCleanup.ts+1-1
......@@ -371,7 +371,7 @@ export const make = Effect.gen(function* () {
371371 return;
372372 yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false });
373373 yield* gitManager.invalidateStatus(project.workspaceRoot);
374 // Preserve branch and path: ProviderCommandReactor recreates the checkout
374 // Preserve branch and path: ProviderTurnStartService recreates the checkout
375375 // from that branch when the thread is resumed.
376376 yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id });
377377 }).pipe(
apps/server/src/testUtils/shardWeights.json+57-35
......@@ -1,40 +1,62 @@
11{
2 "integration/orchestrationEngine.integration.test.ts": 1.9,
3 "integration/providerService.integration.test.ts": 0.5,
4 "src/bin.test.ts": 0.8,
5 "src/checkpointing/CheckpointStore.test.ts": 0.6,
6 "src/device/sshDeviceScript.test.ts": 2.9,
7 "src/git/GitManager.test.ts": 18.7,
8 "src/orchestration/Layers/CheckpointReactor.test.ts": 7.1,
9 "src/orchestration/Layers/OrchestrationEngine.test.ts": 1.4,
10 "src/orchestration/Layers/ProjectionPipeline.test.ts": 0.5,
11 "src/orchestration/Layers/ProviderCommandReactor.test.ts": 3.8,
12 "src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 2.4,
13 "src/persistence/Layers/OrchestrationEventStore.test.ts": 2.2,
14 "src/process/externalLauncher.test.ts": 2.2,
15 "src/project/AgentSessionScanner.test.ts": 4.3,
16 "src/provider/CodexChatGptAuth.test.ts": 5.9,
17 "src/provider/Drivers/AntigravityDriver.test.ts": 2,
18 "src/provider/Layers/AntigravityAdapter.test.ts": 0.5,
19 "src/provider/Layers/ClaudeAdapter.test.ts": 0.7,
20 "src/provider/Layers/CodexCollabRuntime.integration.test.ts": 3.4,
21 "src/provider/Layers/CursorAdapter.test.ts": 8,
22 "src/provider/Layers/CursorProvider.test.ts": 1.4,
23 "src/provider/Layers/GrokAdapter.test.ts": 9,
24 "src/provider/Layers/GrokProvider.test.ts": 0.7,
25 "src/provider/Layers/ProviderService.test.ts": 0.5,
26 "src/provider/acp/AcpJsonRpcConnection.test.ts": 6.2,
27 "src/provider/acp/XAiAcpExtension.test.ts": 0.6,
2 "integration/transferBudgetV2.integration.test.ts": 0.7,
3 "src/auth/SessionStore.test.ts": 0.5,
4 "src/checkpointing/CheckpointStore.test.ts": 0.7,
5 "src/cli/project.test.ts": 0.7,
6 "src/device/sshDeviceScript.test.ts": 3,
7 "src/git/GitManager.test.ts": 22.7,
8 "src/git/detachStackFrame.memory.test.ts": 0.5,
9 "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 9.1,
10 "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 52.3,
11 "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 1.8,
12 "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.1,
13 "src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts": 0.6,
14 "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 0.6,
15 "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 0.8,
16 "src/orchestration-v2/FoundationPersistence.test.ts": 2.6,
17 "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 6,
18 "src/orchestration-v2/ProjectionStore.test.ts": 0.7,
19 "src/orchestration-v2/ProviderSessionManager.test.ts": 0.9,
20 "src/orchestration-v2/ProviderTurnStartService.memory.test.ts": 6.5,
21 "src/orchestration-v2/SelectionRestart.integration.test.ts": 2.5,
22 "src/orchestration-v2/SteeringCompletion.integration.test.ts": 2.7,
23 "src/orchestration-v2/ThreadLaunchService.test.ts": 2,
24 "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 0.5,
25 "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 0.5,
26 "src/orchestration-v2/runtimeLayer.test.ts": 0.9,
27 "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 0.6,
28 "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 53.4,
29 "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 1.4,
30 "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 1.9,
31 "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4,
32 "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7,
33 "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3,
34 "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9,
35 "src/persistence/Layers/Sqlite.test.ts": 0.5,
36 "src/process/externalLauncher.test.ts": 2.3,
37 "src/project/AgentSessionScanner.test.ts": 4.2,
38 "src/project/ManagedProjectFolders.test.ts": 0.5,
39 "src/provider/CodexChatGptAuth.test.ts": 5.4,
40 "src/provider/Drivers/AntigravityDriver.test.ts": 2.3,
41 "src/provider/Layers/GrokProvider.test.ts": 0.9,
42 "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5,
43 "src/provider/OpenCodeServerLedger.test.ts": 1.7,
44 "src/provider/acp/AcpClientTerminals.test.ts": 1.3,
45 "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2,
46 "src/provider/acp/AcpRegistryProbe.test.ts": 1,
47 "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 4.3,
48 "src/provider/cursorSdk.test.ts": 0.7,
49 "src/provider/opencode2/OpenCode2Server.test.ts": 1.2,
2850 "src/provider/opencodeRuntime.environment.test.ts": 1.1,
29 "src/pullRequest/PullRequestService.test.ts": 3.1,
30 "src/server.test.ts": 10.2,
31 "src/serverSettings.test.ts": 0.5,
51 "src/pullRequest/PullRequestService.test.ts": 5.1,
52 "src/rpcInitialItems.memory.test.ts": 5.3,
53 "src/serverSettings.test.ts": 1,
3254 "src/serviceLauncher.test.ts": 6.2,
33 "src/terminal/Manager.test.ts": 1.6,
34 "src/textGeneration/CursorTextGeneration.test.ts": 0.8,
35 "src/textGeneration/GrokTextGeneration.test.ts": 1.2,
55 "src/sourceControl/PrTemplateDetection.test.ts": 0.5,
56 "src/terminal/Manager.test.ts": 1.8,
57 "src/textGeneration/CodexTextGeneration.test.ts": 0.5,
3658 "src/usage/usageTranscriptStreaming.test.ts": 0.8,
37 "src/vcs/GitVcsDriver.test.ts": 3.7,
38 "src/vcs/GitVcsDriverCore.test.ts": 6.5,
39 "src/workspace/WorkspaceEntries.test.ts": 1.9
59 "src/vcs/GitVcsDriver.test.ts": 4.5,
60 "src/vcs/GitVcsDriverCore.test.ts": 6.4,
61 "src/workspace/WorkspaceEntries.test.ts": 2.1
4062}
docs/internals/composer-context-references.md+1-1
......@@ -123,7 +123,7 @@ trailing `<terminal_context>` form is parsed only when reading messages sent by
123123The composer sends `message.text` as canonical prose with reference links and
124124`message.context.records` built from the draft (`buildMessageContext` in
125125`apps/web/src/lib/composerContextRecords.ts`). Expired terminal excerpts are dropped from both.
126The server projects provider text at turn start (`ProviderCommandReactor`), so the persisted
126The server projects provider text at turn start (`projectComposerContextForProvider`), so the persisted
127127message stays readable and the provider receives markers plus one envelope.
128128
129129Review comments and preview annotations enter the draft through store mutators. A mounted composer
docs/internals/devices.md+1-1
......@@ -60,7 +60,7 @@ a shim directory to the provider's PATH. The CLI installs on the environment
6060server even when that server cannot run simulators. Hosts start on demand.
6161
6262That environment is fixed when the provider subprocess spawns, so
63[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)
63[`prepareMcpSession`](../../apps/server/src/orchestration-v2/ProviderSessionManager.ts)
6464starts agent-device only when device support and agent access have both been
6565enabled, the session has the `device` capability, and the machine can run at
6666least one platform. Starting it later from `device_open` would leave the
docs/internals/glossary.md+11-13
......@@ -13,23 +13,21 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain
1313| Workspace root | The project's base filesystem directory on the environment. |
1414| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. |
1515| Thread | The durable conversation and work history for a project. It survives provider process exits. |
16| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. |
16| Turn | One user-to-agent cycle, a V2 run. Provider work can end before checkpoint and diff work settles. |
1717| Activity | A non-message timeline item, such as a tool action, approval, or failure. |
1818| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. |
1919
2020## Orchestration
2121
22| Term | Meaning |
23| ----------------------- | -------------------------------------------------------------------------------------------- |
24| Command | A request to change domain state. Accepting it does not mean its side effects have finished. |
25| Event | A persisted fact produced by a command. |
26| Decider | The pure logic that turns a command and current state into events. |
27| Projection / read model | A view of current state derived from persisted events. |
28| Projector | The logic that applies events to a read model. |
29| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. |
30| Command receipt | A durable record of a command's result, used to make retries idempotent. |
31| Runtime receipt | A test-only signal that an asynchronous milestone completed. |
32| Quiesced | The relevant follow-up workers have finished, beyond the provider turn merely ending. |
22| Term | Meaning |
23| ----------------------- | --------------------------------------------------------------------------------------------------------- |
24| Command | A request to change domain state. Accepting it does not mean its side effects have finished. |
25| Event | A persisted fact produced by a command. |
26| Orchestrator | The service that serializes commands and decides their events from current state, without I/O. |
27| Projection / read model | A persisted view of current state, committed in the same transaction as the events that change it. |
28| Command receipt | A durable record of a command's result, used to make retries idempotent. |
29| Outbox effect | Side-effect intent committed with the events, such as starting a provider turn or capturing a checkpoint. |
30| Effect worker | The worker that runs outbox effects after commit and feeds their results back as commands. |
3331
3432## Providers and checkpoints
3533
......@@ -51,7 +49,7 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain
5149| Term | Meaning |
5250| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5351| Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. |
54| Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. |
52| Pull request sync | The worker that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. |
5553| Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. |
5654
5755## Composer context
docs/internals/providers.md+18-11
......@@ -3,7 +3,7 @@
33Orchestration records intent and state without knowing which provider runs a thread. Provider
44protocols, account ownership, permissions, and capabilities belong at the
55[adapter boundary](../../apps/server/src/orchestration-v2/ProviderAdapter.ts). Normalize there
6instead of spreading provider checks through reactors and clients.
6instead of spreading provider checks through orchestration and clients.
77
88A driver kind identifies an integration; an instance identifies one configuration and account
99lifecycle. Route work by instance, so two accounts using the same driver do not share mutable
......@@ -11,16 +11,23 @@ session or catalog state.
1111
1212## Process and account isolation
1313
14T3-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, while
15T3's MCP connection is thread-scoped. Sharing a chat server between threads in one directory would
16let them replace each other's connection. Catalog and text-generation work can share the
17[instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes
18after an idle period. External OpenCode servers remain externally owned and can require an
19external restart to pick up configuration changes.
20
21OpenCode also stores persistent approval grants per directory. Automatic full-access replies use
22`once` so they cannot widen a supervised thread's permissions on a shared external server.
23See the [adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts).
14The `opencode` driver probes the installed version and runs the 1.x or 2.x runtime. OpenCode's MCP
15registrations are directory-scoped, while T3's MCP connection is thread-scoped, so threads in one
16directory must not share one T3 MCP entry.
17
18- **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's
19 connection. Catalog and text-generation work can share the
20 [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes
21 after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts).
22- **2.x** serves every directory from one
23 [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread
24 registers its own `t3-code-<thread>` MCP entry, and session permission rules deny every other
25 thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts).
26
27External OpenCode servers remain externally owned and can require an external restart to pick up
28configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic
29full-access replies use `once` so they cannot widen a supervised thread's permissions on a shared
30server. On 2.x, a session-wide approval also replies `once` and becomes T3's own rule on that session.
2431
2532Pi runs the user's own `pi` install in RPC mode and owns native extension, package, and project
2633trust discovery. T3 injects only its namespaced MCP bridge, so a Pi session behaves as it does in
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts+3-3
......@@ -19,9 +19,9 @@ const EFFECT_RUNTIME_METHODS = new Set([
1919 "runSyncWith",
2020]);
2121
22// Existing manual runners are tracked as debt through the `maxOccurrences`
23// option, set per file in the lint config. The rule permits no net-new
24// occurrences in those files, while every other test file must have zero.
22// The lint config can set `maxOccurrences` per file to track existing manual
23// runners as debt. The rule permits no net-new occurrences in those files,
24// while every other test file must have zero.
2525const readMaxOccurrences = (options: ReadonlyArray<unknown>): number => {
2626 const [first] = options;
2727 return typeof first === "object" &&
scripts/mobile-showcase-environment.ts+9-4
......@@ -427,7 +427,11 @@ function insertThread(
427427 .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt);
428428}
429429
430const SEEDED_PROJECTION_TABLES = [
430// V1 tables this seed owns. `projection_projects` is not listed: V2 still
431// stores projects there, so the seed upserts its own rows instead. V2 clients
432// do not read the V1 thread rows; moving the seed to V2 is tracked in
433// https://github.com/pingdotgg/t3code/issues/15013.
434const SEEDED_V1_TABLES = [
431435 "projection_pending_approvals",
432436 "projection_thread_proposed_plans",
433437 "projection_thread_activities",
......@@ -435,10 +439,11 @@ const SEEDED_PROJECTION_TABLES = [
435439 "projection_thread_sessions",
436440 "projection_turns",
437441 "projection_threads",
438 "projection_projects",
439442 "projection_state",
440443] as const;
441444
445const SEEDED_PROJECTION_TABLES = [...SEEDED_V1_TABLES, "projection_projects"] as const;
446
442447const SEEDED_THREAD_COLUMNS = ["snoozed_until", "snoozed_at"] as const;
443448
444449function hasSeedableSchema(dbPath: string): boolean {
......@@ -491,11 +496,11 @@ function seedDatabase(
491496 const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 });
492497 try {
493498 database.exec("BEGIN IMMEDIATE");
494 for (const table of SEEDED_PROJECTION_TABLES) {
499 for (const table of SEEDED_V1_TABLES) {
495500 database.exec(`DELETE FROM ${table}`);
496501 }
497502 const insertProject = database.prepare(
498 `INSERT INTO projection_projects (
503 `INSERT OR REPLACE INTO projection_projects (
499504 project_id, title, workspace_root, default_model_selection_json, scripts_json,
500505 created_at, updated_at, deleted_at
501506 ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`,
vite.config.ts-21
......@@ -333,27 +333,6 @@ export default defineConfig({
333333 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],
334334 },
335335 },
336 // Legacy manual Effect runners tracked as debt: no net-new occurrences.
337 // Lower a ceiling when you migrate a file, and delete its entry at zero.
338 ...Object.entries({
339 "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42,
340 "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5,
341 "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4,
342 "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66,
343 "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29,
344 "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2,
345 "apps/server/src/orchestration/commandInvariants.test.ts": 5,
346 "apps/server/src/orchestration/projector.test.ts": 20,
347 "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1,
348 "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1,
349 "apps/server/src/provider/Layers/CursorProvider.test.ts": 1,
350 "apps/server/src/provider/Layers/ProviderService.test.ts": 2,
351 "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12,
352 "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1,
353 }).map(([file, maxOccurrences]) => {
354 const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }];
355 return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } };
356 }),
357336 ],
358337 options: {
359338 reportUnusedDisableDirectives: "error",