From 219552a9e1fc1a7899e9c549d281cba86ad67562 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 19:28:47 -0700 Subject: [PATCH 01/23] docs(lastcode): plan Codex thread tools --- docs/lastcode/codex-thread-tools-plan.md | 476 +++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 docs/lastcode/codex-thread-tools-plan.md diff --git a/docs/lastcode/codex-thread-tools-plan.md b/docs/lastcode/codex-thread-tools-plan.md new file mode 100644 index 000000000000..91572758f9dc --- /dev/null +++ b/docs/lastcode/codex-thread-tools-plan.md @@ -0,0 +1,476 @@ +# Codex Thread Tools Plan + +## Outcome + +Give Codex an official, small LastCode-provided command for identifying its current +LastCode thread, inspecting another thread, sending that thread a user-directed +message, and waiting for the exact resulting turn when the user asks a question. + +The feature is intentionally temporary and LastCode-only. It serves one user running +a handful of concurrent Codex threads across a few personally administered hosts. It +does not establish a general multi-agent orchestration platform. + +## Source of Truth and Prior Art + +This file is the source of truth for scope, sequencing, acceptance, and validation. +The implementation should reuse ideas, but not wholesale branches, from: + +- upstream T3 Code PR #2829, especially its `current/list/read/send/wait` MCP tool + semantics; +- upstream T3 Code PR #3004, especially server-authoritative current thread and + workspace identity; +- the existing `t3` CLI's authenticated live-server and offline projection pattern in + `apps/server/src/cli/project.ts`; and +- the existing LastCode shell snapshot, bounded thread detail, dispatch, event stream, + and projected turn/message correlation. + +PR #6573 is not the implementation base. Its V1 `list/send` behavior is narrower than +the read and exact-wait behavior required here. + +## Product Contract + +Codex receives a discoverable `lastcode-thread` executable in its `PATH`. The command +also remains reachable through the bundled `t3 thread` command tree so the wrapper has +no business logic of its own. + +The initial command surface is: + +```text +lastcode-thread current --json +lastcode-thread list --json +lastcode-thread read [--turn-limit ] --json +lastcode-thread send --message --json +lastcode-thread wait '' [--timeout ] --json +lastcode-thread send --message --wait [--timeout ] --json +``` + +Human-readable output may be added where it is essentially free, but stable JSON is +the primary Codex interface. Every successful result includes a scoped identity with +`environmentId` and `threadId`; current-thread output also includes project, workspace, +provider instance, and provider-native Codex thread identity when available. + +Commands that accept a plain thread ID always address the server on which the command +is running. Cross-host addressing is performed by choosing the SSH host first, not by +embedding an environment selector in a local command. Wait handles carry and validate +the environment ID so they cannot be resumed against the wrong server. + +Codex performs cross-host lookup by running the same command through the user's +existing SSH aliases. The command itself is local-only. A normal lookup is: + +```text +local lastcode-thread read + -> if absent, ssh ~/.lastcode/userdata/bin/lastcode-thread read +``` + +There is no LastCode host registry, SSH configuration parser, credential delegation, +desktop connection-catalog dependency, or server-to-server transport in this plan. +The example path is the default LastCode home; a host configured with a custom base +directory uses that explicit home's `userdata/bin/lastcode-thread` path. + +Plain `send` returns `{ kind: accepted, environmentId, threadId, messageId }` and does +not enable later waiting. Only `send --wait` marks a request for correlation. Its +`timed-out`, `transport-unknown`, and `dispatch-unknown` outcomes include an opaque +wait handle containing its scoped thread +identity and new message ID: +`{ kind: wait-handle, environmentId, threadId, messageId }`. A timed-out result nests +that object as `{ kind: timed-out, waitHandle }`; `wait` accepts the nested handle +object from any of those outcomes, serialized as compact JSON and passed as one +shell-quoted UTF-8 argument, never the plain accepted result. The decoder accepts +exactly the typed handle schema and +rejects extra/missing fields. A timed-out handle can be +passed back to `wait`, which resumes waiting for that specific message's projected +provider turn and resulting assistant response. It never interprets an arbitrary newer +turn as the answer. + +`read` and successful `wait` CLI output share a 64,000-character +transcript/assistant-text presentation budget. JSON includes `textTruncated` and +`originalTextChars` when selected content exceeds that budget; metadata and identifiers +are never truncated. The live server may hydrate its existing bounded-turn detail +snapshot before the CLI applies this output bound; this temporary local transport does +not add a second text-limited SQL/query stack. + +## Deliberate Constraints + +- Codex only. No Claude, Cursor, Grok, OpenCode, or generic provider abstraction. +- User-directed use only. No autonomous coordinator mode, recursively delegated + workflows, or background callback scheduler. +- Expected scale is fewer than ten threads and a few hosts. Prefer bounded linear + scans and one request per inspected host over caches, brokers, queues, or indexes. +- No UI changes on web, desktop, or mobile. +- No MCP dependency and no separate daemon. +- No thread creation, interrupt, fork, merge, worktree handoff, or scheduled task + commands. +- No content search or standalone resolver command in the first version. `list` plus + exact or unambiguous ID-prefix resolution inside `read` and `send` is sufficient. +- No fuzzy resolution. Ambiguous prefixes fail closed and return candidates. +- No compatibility layer for pre-feature binaries or schemas. This is a single-stack + LastCode feature and may evolve with its caller. +- No direct SQL in the wrapper or Codex instructions. Server-owned CLI/query services + own persistence details. +- No offline mutation. Read commands may use the existing offline projection fallback; + `send` and `wait` require the live owning LastCode server. + +## Slice and Pull Request Stack + +The umbrella branch is `lastcode/codex-thread-tools`, opened against +`lastcode/main`. Its first commit is this reviewed plan. The umbrella PR remains open +until the user explicitly rubberstamps the assembled feature. + +Each serial slice starts from the latest umbrella head, opens a PR targeting the +umbrella branch, and is squash-merged into the umbrella only after its own validation +and review gates pass. Later slices are created after the preceding squash merge so +they never retain obsolete pre-squash ancestry. + +### Slice 1: Codex identity and read-only inspection + +Branch: `lastcode/codex-thread-read` + +1. Add a `t3 thread` command group and `current --json` using the existing Effect CLI + patterns. Reuse or extract the CLI runner that resolves the active home, discovers a + live server, issues and revokes a short-lived local session, calls the typed + authenticated HTTP API, and falls back to projection queries for offline reads. + Issue only the minimum orchestration-read scope and revoke it on success, failure, + interruption, or timeout; do not copy the project command's administrative scope. +2. Materialize a tiny runtime wrapper under the active T3/LastCode state directory and + prepend that bin directory only to Codex provider processes. +3. Pass the authoritative LastCode thread ID and active home through the Codex + adapter/runtime input and inject only `T3CODE_THREAD_ID` and `T3CODE_HOME` into the + Codex process. `current` derives environment, project, workspace, and provider + metadata from that thread's server-owned shell/detail state and the existing + environment-descriptor endpoint. Preserve `CODEX_THREAD_ID` as a separately named + provider-native identity when available; never conflate it with the LastCode thread + ID. +4. Make the wrapper pin its owning home explicitly on every invocation. For an ordinary + Node-hosted server it executes that server's runtime and bundled CLI entry with + `--base-dir `. For packaged macOS LastCode it executes the LastCode + binary with `ELECTRON_RUN_AS_NODE=1`, the bundled server CLI entry, and the same + explicit base directory. The wrapper contains invocation details only and delegates + all behavior to `t3 thread`. Windows packaged hosts are out of scope. +5. Add bounded `list` and `read` commands over the existing shell and thread-detail + snapshots. `read` accepts an exact or unambiguous thread-ID prefix and returns + candidates when resolution is ambiguous. +6. Default `read` to a small recent-turn window and impose a conservative maximum. + Include thread status, project/workspace/branch, recent turns, and transcript + content needed to answer “what is this thread up to?” without dumping the full + database. +7. For offline transcript reads, call the bounded thread-detail projection query + directly rather than the command read model, which intentionally omits hydrated + thread bodies. +8. Preserve lifecycle visibility for active snoozed, settled, pending-input, and + working threads. Do not mutate those states. Archived and deleted threads are out + of scope and return not found. +9. Document the supported local-first/SSH composition for Codex. Do not add host + discovery code. +10. Add focused tests for new/resumed Codex identity propagation, environment injection, + active-home selection, wrapper/desktop/SSH invocation, live-server and offline-detail + reads, least-privilege authorization cleanup, bounds, ambiguity, not-found, + lifecycle state, JSON schema, and missing current context. + +Acceptance: + +- `current` identifies the exact environment, LastCode thread, project, workspace, and + provider identity without SQLite/transcript heuristics, and non-Codex providers are + unchanged. +- Codex can list a small host's threads and inspect a supplied exact or unique-prefix + ID through the supported command. +- The same command works when invoked explicitly over SSH on another LastCode host. +- Read operations never wake, unsnooze, unsettle, or otherwise modify a target thread. + +### Slice 2: User-directed tell/send + +Branch: `lastcode/codex-thread-send` + +1. Add live-server-only `send` using the existing authenticated orchestration dispatch + endpoint and `thread.turn.start` command. Issue only the orchestration-read and + orchestration-operate scopes needed for target lookup and dispatch, and revoke them + on every exit path. +2. Generate and retain the new user message ID before dispatch. Return + `{ kind: accepted, environmentId, threadId, messageId }`; this is deliberately not a + wait handle and cannot be passed to `wait`. +3. Resolve the target from the current shell snapshot and use its existing runtime and + interaction settings rather than inventing defaults. +4. Bound message text with the existing provider send-turn input limit and reject an + oversized message before dispatch. +5. Reject deleted or missing local targets with typed errors. Let the existing decider + remain authoritative for lifecycle and concurrency validity rather than duplicating + orchestration rules in the CLI. +6. Add focused tests for successful dispatch, exact command payload, least-privilege + scope issuance and cleanup, live-server requirement, invalid target, oversized + input, decider rejection, exact/prefix ambiguity, and accepted-result encoding. + +Acceptance: + +- A user can tell Codex “Tell THREAD_ID to ...”, and Codex can dispatch the instruction + to that exact local or explicitly SSH-addressed thread. +- The command reports accepted persistence, not successful completion. +- Dispatch failures are explicit and never reported as accepted. + +### Slice 3: Exact ask/wait + +Branch: `lastcode/codex-thread-wait` + +1. Extend CLI `send` with `trackRequestCorrelation: true` only for `send --wait`. + Standalone `wait` accepts a strict `kind: wait-handle` object previously returned by + a timed-out, transport-unknown, or dispatch-unknown `send --wait`/`wait` outcome. Add + the + optional literal field to the client turn-start command, normalized internal + command, and turn-start-requested payload; preserve it through the normalizer and + decider. Absence means current plain-send/UI behavior. + Generate stable command and message IDs before dispatch. If the dispatch response is + lost, retry once with the same IDs so the engine's command receipt deduplication can + return the original acceptance. If the response remains ambiguous, return + `{ kind: dispatch-unknown, waitHandle }` without claiming acceptance; `wait` either + finds the persisted correlation or returns correlation-not-found. +2. Add one narrow `projection_turn_request_correlations` table keyed by + `{threadId, messageId}`, with nullable `turnId`, `state = pending | started | error | +interrupted`, `requestedAt`, and `resolvedAt`. Project every existing + correlation-tracked `thread.turn-start-requested` event into an idempotent pending + row. The tracking marker is carried from the CLI's start command to its request + event; events from before this feature and ordinary UI starts do not create rows. + Absence of the marker is covered by a negative projection test. + Do not change the current single-pending-row behavior, ingestion assumptions, + cursor, or pagination of `projection_turns`. + Delete a thread's correlation rows when that thread is deleted; otherwise retain the + small per-tracked-request history with the thread and add no cleanup scheduler. +3. For marked requests only, keep the originating message ID in the + `ProviderCommandReactor` closure that already + receives `thread.turn-start-requested`; do not widen generic provider start + contracts with a LastCode message ID. Funnel every pre-turn exit—missing/deleted + context, missing/invalid message, request construction, provider start error, and + interruption—through one finalization helper. +4. Add one internal `thread.turn-request.resolve` command and + `thread.turn-request-resolved` event with `{threadId, messageId, outcome}`, where + outcome is either `{ kind: started, turnId }` or + `{ kind: terminal, state: error | interrupted, completedAt }`. Derive its command ID + deterministically only from the originating `thread.turn-start-requested` event ID, + not outcome fields or timestamps, so exactly one resolution can persist and reactor + retry/replay is idempotent. Add the schemas to the existing + orchestration unions, but keep it out of the public web/mobile thread-detail stream; + it is internal bookkeeping, not user-visible activity. Its decider path validates + the correlation identifiers but does not require the target thread to still exist, + so a deletion race can resolve or harmlessly no-op the row. +5. Project `thread.turn-request-resolved` through one transactional, idempotent + repository operation on the correlation table. Started outcomes set the exact + `turnId`; pre-turn terminal outcomes set `error` or `interrupted`. It never changes + `projection_turns`, overwrites an existing terminal outcome, or associates one + message with two turns. Resolution is update-only and no-ops when deletion already + removed the keyed row, so late resolution cannot recreate deleted state. Public JSON + uses `error`, not `failed`. + The reactor finalizer is one-shot: whichever `started`, `error`, or `interrupted` + outcome first persists for the deterministic request command wins; later competing + calls deduplicate. Repeated finalizer calls may carry different timestamps without + changing identity. +6. Add a typed authenticated HTTP wait endpoint. Its success schema is a tagged outcome + union for `completed`, `error`, `interrupted`, and `timed-out`; timeout and terminal + outcomes are HTTP successes, while + typed thread-not-found, correlation-not-found, wrong-environment, query, and existing + authorization errors map to explicit route errors/statuses. A deleted thread returns + thread-not-found immediately; an existing thread with an invalid or unprojected + handle returns correlation-not-found. Give it a conservative + server-side maximum duration and a separate CLI timeout appropriate for a long-held + wait. Set the CLI transport deadline beyond the requested server deadline so the + tagged `timed-out` response normally wins; do not inherit the existing one-second + project-command timeout. If the local transport deadline or connection fails first, + return `{ kind: transport-unknown, waitHandle }` without claiming that the server + timed out or that the turn is unfinished, then revoke the read-only credential. The + command issues only orchestration-read scope and revokes it on every exit path. + Only `completed` guarantees `turnId` and bounded assistant response text. Pre-turn + `error` or `interrupted` outcomes omit both; post-start terminal outcomes may include + `turnId` but do not invent assistant text. +7. Extract the existing WebSocket subscription's buffered subscribe-before-read race + pattern into a small internal helper that accepts its own event predicate. Use it for + wait without exposing the correlation event to the public thread stream: subscribe + to the owning thread's raw domain events before the initial correlation/turn + projection read, then re-read only when relevant events arrive. Do not sleep or poll. +8. Once correlation supplies a turn ID, read terminal state and assistant response from + the existing exact turn/thread projections. If the exact turn row is not projected + yet, remain subscribed and re-read on that thread's session/turn events; cover both + correlation-first and runtime-projection-first orderings. Treat completed, error, + and interrupted distinctly. + Timeout returns a resumable wait handle and does not interrupt the target. +9. Validate the wait handle's environment ID against the local server, then add `wait` + and `send --wait` composition. On success, return the exact turn identity, + terminal state, and completed assistant response correlated to the sent message. + `send --wait` revokes its read/operate dispatch credential immediately after accepted + persistence, writes exactly one recovery line to stderr as + `LASTCODE_WAIT_HANDLE=` without adding another stdout record, + then issues a separate read-only credential for the long wait; operate privilege is + never retained across waiting, timeout, interruption, or resume. User interruption + may end the final result stream, but the already-emitted handle remains available. + The recovery line is a machine-readable framing convention, not a shell assignment + to `eval` or `source`; Codex extracts the JSON value and passes it back as one quoted + argument. +10. Add focused migration/repository, command/event union, decider, + reactor-finalization, transactional + projection-ordering, route, and CLI tests for immediate completion, + pending-before-provider failure, pending-to-running-to-completed, + failure/interruption, timeout-and-resume, event-before-subscribe race protection, + unrelated thread/turn events, wrong-environment handles, concurrent UI/tool sends, + thread-deletion cleanup, and server restart after the correlation event is durable. + A restart before provider adoption—or in the narrow interval after provider + acceptance but before the correlation outcome persists—exercises the existing V1 + limitation: wait times out with the same resumable handle and does not claim + completion or retry automatically. Closing that external-side-effect durability gap + is explicitly outside this temporary feature. Also test distinct plain-accepted and + timed-out-handle encoding, wait-after-thread-deletion, missing correlation, duplicate + finalization, competing outcomes, absent tracking, long-wait timeout configuration, + least-privilege authorization cleanup, and bounded response output. + Cover lost dispatch response with same-command retry and `dispatch-unknown`, plus + interruption after accepted persistence with early handle emission and credential + cleanup. Deterministically force a wait connection/deadline failure and assert + `transport-unknown`, handle recovery, credential cleanup, exactly one recovery line + on stderr, and exactly one final JSON object on stdout. + +Acceptance: + +- “Ask THREAD_ID ...” can send one message and wait for the exact resulting turn. +- An unrelated newer turn can never be mistaken for the requested answer. +- A known but unresolved correlation times out visibly; an invalid or unprojected + handle returns correlation-not-found. Neither is guessed from a newer turn. +- “Do this, and when finished tell THREAD_ID ...” needs no workflow engine: Codex runs + its local work and then calls `send`. + +## Validation and Review + +### Plan gate + +- Run up to ten full plan-review rounds with Luna at high reasoning over `basic`, + `best-practices`, and `KISS` lenses; skip the UI/component lens because the plan has + no UI surface. +- Stop early when every applicable lens is quiet. Reopen a quiet lens if a later review + materially changes scope, architecture, validation, or acceptance. +- Record applied, defended, and deferred findings in this file. + +### Slice gate + +For every slice: + +1. Implement with a Sol-medium subagent on the slice branch. +2. Run the smallest focused tests, formatting/lint checks, and affected package + typechecks required by the slice. +3. Run up to five Luna-high implementation-review rounds over correctness, KISS, and + repository best practices; add UX only where command behavior warrants it and skip + UI/component review. +4. Apply or concretely defend every finding, rerun affected validation, and require all + applicable lenses to become quiet. +5. Push the exact reviewed head, open the slice PR against the umbrella branch, inspect + all current-head comments/reviews/checks and unresolved threads, and request Codex + review if available. +6. Squash-merge only with an explicit exact-head match after focused validation and + review gates are clean. + +The repository's `pnpm lastcode:merge` command intentionally rejects PRs whose base is +not `lastcode/main`, so it cannot merge slice PRs. For each slice, perform the same +open/non-draft/base/head/mergeability/unresolved-thread checks manually and use GitHub's +`--match-head-commit` squash merge. Do not run the nightly checkpoint trigger for slice +merges. + +### Umbrella gate + +After all slices are merged into the umbrella: + +- run focused end-to-end CLI tests for `current`, `list/read`, `send`, and exact + `wait`; +- run `vp check`, `vp run typecheck`, `git diff --check`, and `pnpm lastcode:ci` on the + exact clean umbrella head against the fetched `origin/lastcode/main` base; +- run a final Luna-high assembled-stack review if slice merges or integration fixes + materially changed cross-slice behavior; +- report the exact umbrella head, validation, review state, unresolved-thread count, + and remaining risks; and +- leave the umbrella PR open. Do not run `pnpm lastcode:merge` until the user explicitly + rubberstamps it. + +Manual app QA is not required because this is a backend/CLI-only feature. A bounded +command-level smoke test may use a disposable LastCode home; never run a server against +the user's live `~/.lastcode/userdata` database. + +The user's explicit request to implement and babysit this stack invokes the +`implement-plan` and guarded LastCode delivery workflows and authorizes their final +repo-wide validation commands despite the repository's normal focused-check default. + +## Review Record + +Requested depth: up to 10 rounds with Luna at high reasoning. Review stopped after +round 7 because every applicable lens was quiet. The UI/component lens was skipped +because the plan has no UI surface. Across all rounds, 58 findings were applied, 6 were +defended to preserve the user's explicit scope or delivery workflow, and 0 were +deferred. Intermediate designs mentioned below were superseded by later KISS rounds; +the product contract and three slices above are the implementation source of truth. + +- Round 1 `basic`: six findings applied. The plan dropped archived reads, minimized + provider identity injection, named the offline detail-query path, clarified local + thread addressing, specified the wait HTTP/timeout contract, and retained durable + message correlation for turns that fail before receiving a provider turn ID. +- Round 1 `best-practices`: eight findings applied. The plan now pins the wrapper's + owning home and packaged invocation, limits the first version to packaged macOS, + uses least-privilege command scopes, bounds send input, carries exact message + correlation through provider-start outcomes, rejects overlapping tool sends, uses + existing terminal-state vocabulary, and reports pre-adoption restart orphaning + without adding automatic recovery. +- Round 1 `KISS`: three findings applied and two defended. The plan removed the + standalone `find` command and wait-handle versioning, and requires reuse of the + existing buffered subscription race pattern. The explicit user-requested review + budgets and final implement-plan/full-CI gate remain; they stop early when quiet and + guard the exact cross-thread behavior that would otherwise be difficult to diagnose. +- Round 2 `basic`: five findings applied. Exact waits now use per-message pending + projection rows and explicit reactor correlation outcomes, `current` names the + environment descriptor as its identity source, `send --wait` accepts the same + exact-or-prefix target as `send`, and transcript/answer output has a concrete + truncation contract. +- Round 2 `best-practices`: six findings applied and one defended. The plan now removes + arbitrary pending-message adoption, defines one deterministic correlation + command/event and a single reactor finalizer, requires atomic order-independent + projection, specifies the HTTP outcome/error union, and uses one fake-clock-testable + adoption deadline. The 64k limit remains a CLI/SSH presentation bound over the + existing local bounded-turn snapshot; a second limited SQL/query surface is not + justified at the stated scale. +- Round 2 `KISS`: five findings applied and one defended. Exact wait correlation moved + into a separate, narrow projection so existing pending-turn ingestion and pagination + remain unchanged; internal correlation events stay out of public streams; orphan + timing and dispatch sequence were removed. Final broad validation remains because + the user explicitly requested the implement-plan and guarded LastCode delivery + workflow. +- Round 3 `basic`: four findings applied and two boundaries defended. Correlation rows + are now feature-era/CLI-only, deletion cleans them up, the internal resolution path + tolerates a deleted thread, and wait handles correlation-before-turn-projection. The + plan explicitly accepts timeout across the existing provider-acceptance/persistence + crash window instead of importing V2 recovery machinery, and records the user's + authorization for final repo-wide delivery validation. +- Round 3 `best-practices`: four findings applied. The tracking marker now has an exact + typed command/normalizer/event path, one stable request-derived resolution command ID + makes outcome persistence one-shot, volatile timestamps do not affect idempotency, + and deleted-thread versus missing-correlation wait errors are distinct. +- Round 3 `KISS`: four findings applied. Current identity and inspection are one + read-only slice, correlation-marker plumbing moves entirely into the wait slice, only + marked requests enter the reactor finalizer, and late resolution updates existing + rows only so deleted state cannot reappear. +- Round 4 `basic`: two findings applied. Plain send and timed-out wait handles now have + distinct schemas, only `send --wait` creates correlation, standalone `wait` only + resumes a timed-out handle, and the slice-specific encoding tests match that split. +- Round 4 `best-practices`: two findings applied. `send --wait` drops operate privilege + before opening a read-only wait session, and resumable handles have their own nested + `kind: wait-handle` schema so a plain accepted result cannot be mistaken for one. +- Round 4 `KISS`: two findings applied. Standalone wait receives one shell-quoted + compact-JSON handle with strict decoding, and the terminal outcome union now states + exactly when turn identity and assistant text are present. +- Round 5 `basic`: one finding applied. Server timeout normally precedes the CLI + transport deadline, while transport failure still returns the already-known handle + and revokes the read-only credential so the wait remains resumable. +- Round 5 `best-practices`: two findings applied. Ambiguous dispatch retries once with + stable IDs and otherwise returns `dispatch-unknown` without claiming acceptance; + confirmed dispatch emits its handle before blocking so interruption preserves a way + to resume while credentials are still cleaned up. +- Round 5 `KISS`: three findings applied. Candidate handles from dispatch, transport, + and timeout outcomes are all valid standalone-wait inputs; connection uncertainty is + `transport-unknown`, not `timed-out`; and interruption recovery uses one precisely + framed stderr line while stdout remains one final JSON object. +- Round 6 `basic`: one finding applied. Focused CLI coverage now forces transport + uncertainty and verifies its handle, credential cleanup, and exact stderr/stdout + framing; the recovery record is explicitly machine-readable rather than shell code. +- Round 6 `best-practices` and `KISS`: clean. No material findings remained. +- Round 7 `basic`: clean. Basic, best-practices, and KISS were all quiet; the plan is + ready for implementation. + +## Implementation Results + +Pending. From 98c256aaf77bbdbd74556d00c083205ef8ea41c9 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 23:55:50 -0700 Subject: [PATCH 02/23] feat(lastcode): add Codex thread inspection (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Codex can infer LastCode session identity by probing environment variables and SQLite, but there is no supported, bounded way to identify, list, or inspect threads—especially when composing the same operation over SSH. ## Solution - add `t3 thread current|list|read` with stable, bounded JSON output; - materialize a home-pinned `lastcode-thread` wrapper and expose it only to Codex processes on supported POSIX hosts; - inject authoritative LastCode thread/home identity and preserve Codex's provider-native thread identity in the session projection; - use least-privilege authenticated live reads with a bounded offline projection fallback; - document explicit local-first/SSH composition without adding host discovery, MCP, or a broker. This PR is slice 1 of umbrella PR #52. It intentionally contains no thread messaging or waiting behavior. ## Validation - five bounded Luna-high review rounds; final correctness, KISS, and best-practices results are clean; - 231 focused tests passed; - focused lint and server/contracts typechecks passed; - repository pre-push quick CI passed: formatting, workspace typechecks, and all workspace tests. Implemented with GPT-5.6 Sol and reviewed with GPT-5.6 Luna in the Codex harness. --- apps/server/src/bin.test.ts | 206 ++++- apps/server/src/bin.ts | 2 + apps/server/src/cli/config.test.ts | 110 ++- apps/server/src/cli/config.ts | 88 ++- apps/server/src/cli/thread.test.ts | 536 +++++++++++++ apps/server/src/cli/thread.ts | 741 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 12 +- .../Layers/ProjectionSnapshotQuery.ts | 2 + .../Layers/ProviderCommandReactor.ts | 6 + .../Layers/ProviderRuntimeIngestion.test.ts | 33 + .../Layers/ProviderRuntimeIngestion.ts | 3 + .../src/orchestration/decider.session.test.ts | 128 +++ apps/server/src/orchestration/decider.ts | 22 +- .../Layers/ProjectionThreadSessions.ts | 4 + apps/server/src/persistence/Layers/Sqlite.ts | 28 + .../Services/ProjectionThreadSessions.ts | 1 + .../src/provider/CodexThreadTool.test.ts | 212 +++++ apps/server/src/provider/CodexThreadTool.ts | 85 ++ .../src/provider/Layers/CodexAdapter.test.ts | 118 ++- .../src/provider/Layers/CodexAdapter.ts | 41 +- docs/lastcode/codex-thread-tools-plan.md | 34 +- docs/user/codex-thread-tools.md | 39 + packages/contracts/src/orchestration.test.ts | 15 + packages/contracts/src/orchestration.ts | 1 + 25 files changed, 2425 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/cli/thread.test.ts create mode 100644 apps/server/src/cli/thread.ts create mode 100644 apps/server/src/orchestration/decider.session.test.ts create mode 100644 apps/server/src/provider/CodexThreadTool.test.ts create mode 100644 apps/server/src/provider/CodexThreadTool.ts create mode 100644 docs/user/codex-thread-tools.md diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..2519b58c44dc 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -3,11 +3,14 @@ import * as NodeHttp from "node:http"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, + EnvironmentId, + EnvironmentMetadataHttpApi, EnvironmentOrchestrationHttpApi, ProviderInstanceId, ThreadId, @@ -17,15 +20,18 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import { ThreadCliOfflineRuntimeLive } from "./cli/thread.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -36,6 +42,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import { makePersistedServerRuntimeState, persistServerRuntimeState, + readPersistedServerRuntimeState, } from "./serverRuntimeState.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -43,7 +50,9 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} +class ProjectCliHttpApi extends HttpApi.make("environment") + .add(EnvironmentMetadataHttpApi) + .add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); const noConnectCli = makeCli({ cloudEnabled: false }); @@ -116,8 +125,21 @@ const readPersistedSnapshot = (baseDir: string) => const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Effect) => Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); + const metadataLayer = HttpApiBuilder.group(ProjectCliHttpApi, "metadata", (handlers) => + Effect.succeed( + handlers.handle("descriptor", () => + Effect.succeed({ + environmentId: EnvironmentId.make("env-thread-live"), + label: "CLI integration", + platform: { os: "linux", arch: "x64" }, + serverVersion: "test", + capabilities: { repositoryIdentity: true }, + }), + ), + ), + ); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( - Layer.provide(orchestrationHttpApiLayer), + Layer.provide(Layer.mergeAll(orchestrationHttpApiLayer, metadataLayer)), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { @@ -572,6 +594,186 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("falls back to bounded SQLite reads without clearing the runtime record", () => + Effect.gen(function* () { + const seedBaseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-seed-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", seedBaseDir]); + const snapshot = yield* readPersistedSnapshot(seedBaseDir); + const project = snapshot.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const seedConfig = yield* makeCliTestServerConfig(seedBaseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-offline-create"), + threadId: ThreadId.make("thread-offline-bounded"), + projectId: project.id, + title: "Offline bounded", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + // Leave the applied schema intact but make the latest migration appear pending. + // A setup-enabled fallback would try to run it; the inspection layer must not. + yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id = 40`; + }).pipe(Effect.provide(makeProjectPersistenceLayer(seedConfig))); + + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-")); + const config = yield* makeCliTestServerConfig(baseDir); + NodeFS.mkdirSync(config.stateDir); + const seedDatabase = new NodeSqlite.DatabaseSync(seedConfig.dbPath); + try { + seedDatabase.exec(`VACUUM INTO '${config.dbPath.replaceAll("'", "''")}'`); + } finally { + seedDatabase.close(); + } + NodeFS.writeFileSync(config.environmentIdPath, "env-thread-offline\n"); + + const unavailableRuntime = { + version: 1 as const, + pid: process.pid, + port: 1, + origin: "http://127.0.0.1:1", + startedAt: "2026-08-21T00:00:00.000Z", + }; + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: unavailableRuntime, + }); + const baseEntriesBefore = NodeFS.readdirSync(baseDir).toSorted(); + const stateEntriesBefore = NodeFS.readdirSync(config.stateDir).toSorted(); + for (const name of stateEntriesBefore) { + NodeFS.chmodSync(NodePath.join(config.stateDir, name), 0o444); + } + NodeFS.chmodSync(config.stateDir, 0o555); + NodeFS.chmodSync(baseDir, 0o555); + const databaseStatBefore = NodeFS.statSync(config.dbPath); + + const { output } = yield* captureStdout( + runCli(["thread", "read", "thread-offline", "--turn-limit", "1", "--base-dir", baseDir]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const result = JSON.parse(output) as { readonly kind: string; readonly threadId: string }; + assert.equal(result.kind, "read"); + assert.equal(result.threadId, "thread-offline-bounded"); + const preservedRuntime = yield* readPersistedServerRuntimeState( + config.serverRuntimeStatePath, + ); + assert.deepStrictEqual(preservedRuntime, Option.some(unavailableRuntime)); + const databaseStatAfter = NodeFS.statSync(config.dbPath); + assert.equal(databaseStatAfter.size, databaseStatBefore.size); + assert.equal(databaseStatAfter.mtimeMs, databaseStatBefore.mtimeMs); + assert.deepStrictEqual(NodeFS.readdirSync(baseDir).toSorted(), baseEntriesBefore); + assert.deepStrictEqual(NodeFS.readdirSync(config.stateDir).toSorted(), stateEntriesBefore); + const verificationDb = new NodeSqlite.DatabaseSync(config.dbPath, { readOnly: true }); + try { + assert.strictEqual( + verificationDb + .prepare("SELECT migration_id FROM effect_sql_migrations WHERE migration_id = 40") + .get(), + undefined, + ); + } finally { + verificationDb.close(); + } + NodeFS.chmodSync(baseDir, 0o755); + NodeFS.chmodSync(config.stateDir, 0o755); + for (const name of stateEntriesBefore) { + NodeFS.chmodSync(NodePath.join(config.stateDir, name), 0o644); + } + }), + ); + + it.effect("keeps the offline thread runtime read-only", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-read-only-runtime-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-read-only-runtime-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* Effect.serviceOption(OrchestrationEngine.OrchestrationEngineService); + const query = yield* Effect.serviceOption(ProjectionSnapshotQuery.ProjectionSnapshotQuery); + const sql = yield* SqlClient.SqlClient; + const writeAttempt = yield* Effect.result( + sql`CREATE TABLE thread_cli_must_remain_read_only (id INTEGER)`, + ); + + assert.isTrue(Option.isNone(engine)); + assert.isTrue(Option.isSome(query)); + assert.strictEqual(writeAttempt._tag, "Failure"); + }).pipe( + Effect.provide( + ThreadCliOfflineRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ); + + it.effect("uses authenticated live shell and detail reads and revokes its CLI sessions", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-live-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-live-workspace-"), + ); + const config = yield* makeCliTestServerConfig(baseDir); + NodeFS.mkdirSync(config.stateDir, { recursive: true }); + NodeFS.writeFileSync(config.environmentIdPath, `${EnvironmentId.make("env-thread-live")}\n`); + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getSnapshot(); + const project = shell.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-live-create"), + threadId: ThreadId.make("thread-live-authenticated"), + projectId: project.id, + title: "Live authenticated", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const before = yield* auth.listSessions(); + NodeFS.unlinkSync(config.environmentIdPath); + const { output } = yield* captureStdout( + runCli(["thread", "read", "thread-live", "--base-dir", baseDir]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const result = JSON.parse(output) as { readonly kind: string; readonly threadId: string }; + assert.equal(result.kind, "read"); + assert.equal(result.threadId, "thread-live-authenticated"); + const after = yield* auth.listSessions(); + assert.equal(after.length, before.length); + }), + ); + }), + ); + it.effect("rejects dev-url on project commands", () => Effect.gen(function* () { const workspaceRoot = NodeFS.mkdtempSync( diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 3370a2299dca..adf3aeea5ff9 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,7 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { triageCommand } from "./cli/triage.ts"; +import { threadCommand } from "./cli/thread.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serviceCommand, servicePreflightCommand, triageCommand, + threadCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..e4fdbcee0867 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -17,8 +17,8 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "../config.ts"; -import { resolveServerConfig } from "./config.ts"; +import { DEFAULT_PORT, deriveServerPaths } from "../config.ts"; +import { resolveServerConfig, resolveThreadInspectionConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: Option.none(), baseDir: Option.none(), cwd: Option.none(), - devUrl: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), @@ -619,4 +619,108 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("pins every derived path to the explicitly active dev state", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "thread-active-home-" }); + const stateDir = join(baseDir, "dev"); + const inheritedDevUrl = new URL("http://127.0.0.1:5173"); + const resolved = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(inheritedDevUrl), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + { activeStateDir: Option.some(stateDir) }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + assert.equal(resolved.baseDir, baseDir); + assert.equal(resolved.stateDir, stateDir); + assert.equal(resolved.devUrl, inheritedDevUrl); + assert.equal(resolved.dbPath, join(stateDir, "state.sqlite")); + assert.equal(resolved.environmentIdPath, join(stateDir, "environment-id")); + assert.equal(resolved.serverRuntimeStatePath, join(stateDir, "server-runtime.json")); + assert.equal(resolved.secretsDir, join(stateDir, "secrets")); + + const userdataStateDir = join(baseDir, "userdata"); + const userdata = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + { activeStateDir: Option.some(userdataStateDir) }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + assert.equal(userdata.stateDir, userdataStateDir); + assert.equal(userdata.devUrl?.href, "http://127.0.0.1:5173/"); + }).pipe(Effect.scoped), + ); + + it.effect("derives thread inspection config without probing ports or provisioning paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "thread-config-read-only-" }); + const baseDir = join(root, "missing-home"); + let portProbeCount = 0; + const netLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.die("unexpected port probe"), + isPortAvailableOnLoopback: () => Effect.die("unexpected port probe"), + hasListenerOnHost: () => Effect.die("unexpected port probe"), + reserveLoopbackPort: () => Effect.die("unexpected port probe"), + findAvailablePort: () => { + portProbeCount += 1; + return Effect.die("unexpected port probe"); + }, + }); + const resolved = yield* resolveThreadInspectionConfig( + { baseDir: Option.some(baseDir) }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), netLayer), + ), + ); + + assert.equal(resolved.port, DEFAULT_PORT); + assert.equal(resolved.baseDir, baseDir); + assert.equal(portProbeCount, 0); + assert.isFalse(yield* fs.exists(baseDir)); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5b05b773b314..a555090fbaa0 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -17,6 +17,11 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +export class CliLocationError extends Schema.TaggedErrorClass()( + "CliLocationError", + { message: Schema.String }, +) {} + export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -159,6 +164,7 @@ export interface CliServerFlags { export interface CliAuthLocationFlags { readonly baseDir: Option.Option; readonly devUrl?: Option.Option; + readonly stateDir?: Option.Option; } export const sharedServerLocationFlags = { @@ -213,6 +219,9 @@ export const resolveServerConfig = ( options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly activeStateDir?: Option.Option; + readonly provisionPaths?: boolean; + readonly discoverPort?: boolean; }, ) => Effect.gen(function* () { @@ -259,7 +268,7 @@ export const resolveServerConfig = ( { onSome: (value) => Effect.succeed(value), onNone: () => { - if (mode === "desktop") { + if (mode === "desktop" || options?.discoverPort === false) { return Effect.succeed(ServerConfig.DEFAULT_PORT); } return findAvailablePort(ServerConfig.DEFAULT_PORT); @@ -281,16 +290,38 @@ export const resolveServerConfig = ( ); const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { - baseDirIsExplicit: Option.isSome(explicitBaseDir), + const provisionPaths = options?.provisionPaths ?? true; + if (provisionPaths) yield* fs.makeDirectory(cwd, { recursive: true }); + const requestedStateDir = yield* Option.match(options?.activeStateDir ?? Option.none(), { + onNone: () => Effect.void, + onSome: (value) => Effect.map(expandHomePath(value.trim()), path.resolve), }); - yield* ServerConfig.ensureServerDirectories(derivedPaths); + const userdataStateDir = path.join(baseDir, "userdata"); + const devStateDir = path.join(baseDir, "dev"); + if ( + requestedStateDir !== undefined && + requestedStateDir !== userdataStateDir && + requestedStateDir !== devStateDir + ) { + return yield* new CliLocationError({ + message: "--state-dir must select the userdata or dev directory within --base-dir.", + }); + } + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + requestedStateDir === userdataStateDir + ? undefined + : requestedStateDir === devStateDir + ? (devUrl ?? new URL("http://127.0.0.1")) + : devUrl, + { baseDirIsExplicit: requestedStateDir === undefined && Option.isSome(explicitBaseDir) }, + ); + if (provisionPaths) yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, ); const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; - yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); + if (provisionPaths) yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); const startupPresentation = options?.startupPresentation ?? "browser"; const isHeadlessStartup = startupPresentation === "headless"; const noBrowser = Option.getOrElse( @@ -391,27 +422,38 @@ export const resolveServerConfig = ( return config; }); +const cliAuthServerFlags = (flags: CliAuthLocationFlags): CliServerFlags => ({ + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), +}); + export const resolveCliAuthConfig = ( flags: CliAuthLocationFlags, cliLogLevel: Option.Option, ) => - resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: flags.baseDir, - cwd: Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - cliLogLevel, - ); + resolveServerConfig(cliAuthServerFlags(flags), cliLogLevel, { + activeStateDir: flags.stateDir ?? Option.none(), + }); + +export const resolveThreadInspectionConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig(cliAuthServerFlags(flags), cliLogLevel, { + activeStateDir: flags.stateDir ?? Option.none(), + provisionPaths: false, + discoverPort: false, + }); const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts new file mode 100644 index 000000000000..e2fceb8171f0 --- /dev/null +++ b/apps/server/src/cli/thread.test.ts @@ -0,0 +1,536 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { + ThreadId, + EnvironmentId, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import { + THREAD_TRANSCRIPT_MAX_CHARS, + THREAD_ACTIVITY_MAX_RESULTS, + THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS, + THREAD_LIST_MAX_RESULTS, + type ThreadReadSource, + boundThreadPresentation, + boundTranscriptMessages, + currentThreadOutput, + listThreadsOutput, + readThreadOutput, + resolveThreadTarget, + threadLifecycle, + validateThreadTurnLimit, + withReadSession, +} from "./thread.ts"; + +const shellThread = (id: string) => ({ id: ThreadId.make(id) }) as OrchestrationThreadShell; + +const activity = (id: string, summary: string, createdAt: string) => + ({ + id, + kind: "tool.completed", + tone: "tool", + summary, + payload: { preserved: id }, + turnId: "turn-presentation", + createdAt, + }) as OrchestrationThread["activities"][number]; + +const requestActivity = ( + id: string, + kind: "approval.requested" | "approval.resolved" | "user-input.requested" | "user-input.resolved", + requestId: string, + summary: string, + createdAt: string, +) => + ({ + ...activity(id, summary, createdAt), + kind, + tone: "approval", + payload: { requestId }, + }) as OrchestrationThread["activities"][number]; + +const runnerSource = () => { + const rawThread = { + id: ThreadId.make("thread-runner"), + projectId: "project-runner", + title: "Runner thread", + updatedAt: "2026-01-02T00:00:00.000Z", + branch: "main", + worktreePath: null, + session: null, + latestTurn: null, + hasPendingUserInput: false, + hasPendingApprovals: false, + backgroundLiveness: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + }; + const thread = rawThread as never; + const limits: number[] = []; + return { + limits, + source: { + descriptor: { environmentId: EnvironmentId.make("env-runner") }, + home: "/tmp/lastcode-home", + shell: { + projects: [ + { + id: "project-runner", + title: "Runner project", + workspaceRoot: "/tmp/workspace", + }, + ], + threads: [thread], + }, + getThread: (_threadId: ThreadId, limit: number) => { + limits.push(limit); + return Effect.succeed({ + thread: { ...rawThread, messages: [], activities: [], latestTurn: null }, + } as never); + }, + } as unknown as ThreadReadSource, + }; +}; + +it("resolves exact ids before unique prefixes", () => { + const exact = shellThread("abc"); + const longer = shellThread("abc-123"); + assert.deepStrictEqual(resolveThreadTarget([exact, longer], "abc"), { + kind: "resolved", + thread: exact, + }); + assert.deepStrictEqual(resolveThreadTarget([exact, longer], "abc-1"), { + kind: "resolved", + thread: longer, + }); +}); + +it("fails closed with candidates for ambiguous prefixes and reports not found", () => { + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1"), shellThread("aaa-2")], "aaa"), { + kind: "ambiguous", + identifier: "aaa", + candidates: ["aaa-1", "aaa-2"], + }); + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1")], "missing"), { + kind: "not-found", + identifier: "missing", + }); + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1")], " "), { + kind: "not-found", + identifier: "", + }); +}); + +it("caps ambiguous candidates deterministically and reports the original count", () => { + const threads = Array.from({ length: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS + 5 }, (_, index) => + shellThread(`shared-${String(index).padStart(2, "0")}`), + ).toReversed(); + const result = resolveThreadTarget(threads, "shared-"); + assert.deepStrictEqual(result, { + kind: "ambiguous", + identifier: "shared-", + candidates: Array.from( + { length: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS }, + (_, index) => `shared-${String(index).padStart(2, "0")}`, + ), + candidatesTruncated: true, + originalCandidateCount: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS + 5, + }); +}); + +it("validates the conservative read window", () => { + assert.strictEqual(validateThreadTurnLimit(1), 1); + assert.strictEqual(validateThreadTurnLimit(20), 20); + assert.throws(() => validateThreadTurnLimit(0)); + assert.throws(() => validateThreadTurnLimit(21)); + assert.throws(() => validateThreadTurnLimit(1.5)); +}); + +it.effect("runs current, list, and bounded read outputs and rejects missing current context", () => + Effect.gen(function* () { + const { source, limits } = runnerSource(); + const current = yield* currentThreadOutput(source, { + threadId: "thread-runner", + home: "/tmp/lastcode-home", + }); + const list = yield* listThreadsOutput(source); + const read = yield* readThreadOutput(source, "thread-r", 7); + const missing = yield* Effect.result(currentThreadOutput(source, {})); + + assert.strictEqual(current.kind, "current"); + assert.strictEqual(current.threadId, "thread-runner"); + assert.strictEqual(list.kind, "list"); + assert.strictEqual(list.threads[0]?.threadId, "thread-runner"); + assert.isFalse("threadsTruncated" in list); + assert.isFalse("originalThreadCount" in list); + assert.strictEqual(read.kind, "read"); + assert.deepStrictEqual(limits, [7]); + assert.strictEqual(missing._tag, "Failure"); + }), +); + +it.effect("caps thread lists deterministically and reports truncation", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const originalThreadCount = THREAD_LIST_MAX_RESULTS + 5; + const threads = Array.from({ length: originalThreadCount }, (_, index) => ({ + ...source.shell.threads[0]!, + id: ThreadId.make(`thread-${String(index).padStart(2, "0")}`), + updatedAt: "2026-01-02T00:00:00.000Z", + })).toReversed(); + const list = yield* listThreadsOutput({ + ...source, + shell: { ...source.shell, threads }, + }); + + assert.strictEqual(list.threads.length, THREAD_LIST_MAX_RESULTS); + assert.deepStrictEqual( + list.threads.map(({ threadId }) => threadId), + Array.from( + { length: THREAD_LIST_MAX_RESULTS }, + (_, index) => `thread-${String(index).padStart(2, "0")}`, + ), + ); + assert.strictEqual(list.threadsTruncated, true); + assert.strictEqual(list.originalThreadCount, originalThreadCount); + }), +); + +it("keeps pending-input, working, snoozed, settled, and active lifecycle states visible", () => { + const lifecycleThread = (overrides: Partial) => + ({ + hasPendingUserInput: false, + hasPendingApprovals: false, + latestTurn: null, + session: null, + backgroundLiveness: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + ...overrides, + }) as OrchestrationThreadShell; + assert.strictEqual( + threadLifecycle(lifecycleThread({ hasPendingUserInput: true }), { + now: "2026-06-01T00:00:00.000Z", + }), + "pending-input", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ session: { status: "running" } as never }), { + now: "2026-06-01T00:00:00.000Z", + }), + "working", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ snoozedUntil: "2026-12-01T00:00:00.000Z" as never }), { + now: "2026-06-01T00:00:00.000Z", + }), + "snoozed", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ settledOverride: "settled" }), { + now: "2026-06-01T00:00:00.000Z", + }), + "settled", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({}), { now: "2026-06-01T00:00:00.000Z" }), + "active", + ); +}); + +it("matches effective snooze expiry, precedence, and raised-hand behavior", () => { + const base = { + hasPendingUserInput: false, + hasPendingApprovals: false, + latestTurn: null, + session: null, + backgroundLiveness: null, + snoozedUntil: "2026-06-02T00:00:00.000Z", + snoozedAt: "2026-05-31T12:00:00.000Z", + settledOverride: null, + settledAt: null, + } as unknown as OrchestrationThreadShell; + const now = "2026-06-01T00:00:00.000Z"; + assert.strictEqual(threadLifecycle(base, { now }), "snoozed"); + assert.strictEqual( + threadLifecycle({ ...base, snoozedUntil: "2026-05-31T00:00:00.000Z" } as never, { now }), + "active", + ); + assert.strictEqual( + threadLifecycle({ ...base, hasPendingApprovals: true } as never, { now }), + "pending-input", + ); + assert.strictEqual( + threadLifecycle({ ...base, session: { status: "running" } } as never, { now }), + "snoozed", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + session: { status: "error", updatedAt: "2026-06-01T01:00:00.000Z" }, + } as never, + { now }, + ), + "active", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + session: { status: "error", updatedAt: "2026-05-31T11:00:00.000Z" }, + } as never, + { now }, + ), + "snoozed", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + latestTurn: { + state: "completed", + completedAt: "2026-06-01T01:00:00.000Z", + }, + } as never, + { now }, + ), + "active", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + latestTurn: { + state: "completed", + completedAt: "2026-05-31T11:00:00.000Z", + }, + } as never, + { now }, + ), + "snoozed", + ); +}); + +it("keeps recent transcript text within the presentation budget without dropping metadata", () => { + const message = (id: string, text: string): OrchestrationMessage => ({ + id: id as OrchestrationMessage["id"], + role: "assistant", + text, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z" as OrchestrationMessage["createdAt"], + updatedAt: "2026-01-01T00:00:00.000Z" as OrchestrationMessage["updatedAt"], + }); + const result = boundTranscriptMessages([ + message("old", "o".repeat(100)), + message("new", "n".repeat(THREAD_TRANSCRIPT_MAX_CHARS)), + ]); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, THREAD_TRANSCRIPT_MAX_CHARS + 100); + assert.strictEqual(result.messages[0]?.id, "old"); + assert.strictEqual(result.messages[0]?.text, ""); + assert.strictEqual(result.messages[1]?.text.length, THREAD_TRANSCRIPT_MAX_CHARS); +}); + +it("bounds huge activity summaries and preserves their metadata", () => { + const huge = activity( + "activity-huge", + `prefix-${"s".repeat(THREAD_TRANSCRIPT_MAX_CHARS)}`, + "2026-01-03T00:00:00.000Z", + ); + const result = boundThreadPresentation([], [huge]); + assert.strictEqual(result.activities[0]?.summary.length, THREAD_TRANSCRIPT_MAX_CHARS); + assert.match(result.activities[0]?.summary ?? "", /^s+$/); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, huge.summary.length); + assert.deepStrictEqual( + { + id: result.activities[0]?.id, + kind: result.activities[0]?.kind, + tone: result.activities[0]?.tone, + payload: result.activities[0]?.payload, + turnId: result.activities[0]?.turnId, + createdAt: result.activities[0]?.createdAt, + }, + { + id: huge.id, + kind: huge.kind, + tone: huge.tone, + payload: huge.payload, + turnId: huge.turnId, + createdAt: huge.createdAt, + }, + ); +}); + +it("caps activity records to the most recent entries while retaining their original order", () => { + const activities = Array.from({ length: THREAD_ACTIVITY_MAX_RESULTS + 5 }, (_, index) => + activity( + `activity-${index}`, + "x", + `2026-01-${String(Math.floor(index / 24) + 1).padStart(2, "0")}T${String(index % 24).padStart(2, "0")}:00:00.000Z`, + ), + ); + const result = boundThreadPresentation([], activities); + assert.strictEqual(result.activities.length, THREAD_ACTIVITY_MAX_RESULTS); + assert.strictEqual(result.activities[0]?.id, "activity-5"); + assert.strictEqual(result.activities.at(-1)?.id, `activity-${activities.length - 1}`); + assert.strictEqual(result.activitiesTruncated, true); + assert.strictEqual(result.originalActivityCount, activities.length); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, activities.length); +}); + +it("retains an old unresolved request before filling the activity cap with recent entries", () => { + const pending = requestActivity( + "approval-pending", + "approval.requested", + "request-pending", + "Approval required", + "2025-12-31T00:00:00.000Z", + ); + const pendingInput = requestActivity( + "user-input-pending", + "user-input.requested", + "input-pending", + "Input required", + "2025-12-31T00:30:00.000Z", + ); + const resolvedRequest = requestActivity( + "user-input-closed", + "user-input.requested", + "request-closed", + "Input required", + "2025-12-31T01:00:00.000Z", + ); + const resolution = requestActivity( + "user-input-resolution", + "user-input.resolved", + "request-closed", + "Input received", + "2025-12-31T02:00:00.000Z", + ); + const recent = Array.from({ length: THREAD_ACTIVITY_MAX_RESULTS + 5 }, (_, index) => + activity( + `activity-${index}`, + "x", + `2026-01-${String(Math.floor(index / 24) + 1).padStart(2, "0")}T${String(index % 24).padStart(2, "0")}:00:00.000Z`, + ), + ); + + const result = boundThreadPresentation( + [], + [pending, pendingInput, resolvedRequest, resolution, ...recent], + ); + + assert.strictEqual(result.activities.length, THREAD_ACTIVITY_MAX_RESULTS); + assert.strictEqual(result.activities[0]?.id, pending.id); + assert.strictEqual(result.activities[1]?.id, pendingInput.id); + assert.strictEqual(result.activities[2]?.id, "activity-7"); + assert.strictEqual(result.activities.at(-1)?.id, `activity-${recent.length - 1}`); + assert.strictEqual( + result.activities.some(({ id }) => id === resolvedRequest.id), + false, + ); + assert.strictEqual( + result.activities.some(({ id }) => id === resolution.id), + false, + ); + assert.strictEqual(result.activitiesTruncated, true); + assert.strictEqual(result.originalActivityCount, recent.length + 4); +}); + +it("reserves presentation text for an old unresolved request explanation", () => { + const pending = requestActivity( + "approval-pending", + "approval.requested", + "request-pending", + "Approval required", + "2025-12-31T00:00:00.000Z", + ); + const recent = activity( + "activity-new", + "n".repeat(THREAD_TRANSCRIPT_MAX_CHARS), + "2026-01-01T00:00:00.000Z", + ); + + const result = boundThreadPresentation([], [pending, recent]); + + assert.strictEqual(result.activities[0]?.summary, pending.summary); + assert.strictEqual( + result.activities[1]?.summary.length, + THREAD_TRANSCRIPT_MAX_CHARS - pending.summary.length, + ); + assert.match(result.activities[1]?.summary ?? "", /^n+$/); + assert.strictEqual( + result.activities.reduce((total, item) => total + item.summary.length, 0), + THREAD_TRANSCRIPT_MAX_CHARS, + ); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.activitiesTruncated, false); +}); + +it("shares one text budget across messages and activities, favoring newer content", () => { + const oldMessage = { + id: "message-old", + role: "assistant", + text: `old-${"m".repeat(39_996)}`, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as OrchestrationMessage; + const newerActivity = activity( + "activity-new", + `new-${"a".repeat(39_996)}`, + "2026-01-02T00:00:00.000Z", + ); + const result = boundThreadPresentation([oldMessage], [newerActivity]); + assert.strictEqual(result.activities[0]?.summary, newerActivity.summary); + assert.strictEqual(result.messages[0]?.text.length, 24_000); + assert.match(result.messages[0]?.text ?? "", /^m+$/); + assert.strictEqual( + (result.messages[0]?.text.length ?? 0) + (result.activities[0]?.summary.length ?? 0), + THREAD_TRANSCRIPT_MAX_CHARS, + ); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.activitiesTruncated, false); +}); + +it.effect( + "issues the read-only scope and revokes it after success, failure, and timeout failure", + () => + Effect.gen(function* () { + const issuedScopes: string[][] = []; + const revoked: string[] = []; + const auth = { + issueSession: ({ scopes }: { scopes: string[] }) => { + issuedScopes.push(scopes); + return Effect.succeed({ sessionId: `session-${issuedScopes.length}`, token: "token" }); + }, + revokeSession: (sessionId: string) => { + revoked.push(sessionId); + return Effect.void; + }, + } as never; + + yield* withReadSession(auth, () => Effect.succeed("ok")); + yield* Effect.result(withReadSession(auth, () => Effect.fail("failed"))); + yield* Effect.result( + withReadSession(auth, () => Effect.fail({ _tag: "TimeoutException" as const })), + ); + + assert.deepStrictEqual(issuedScopes, [ + ["orchestration:read"], + ["orchestration:read"], + ["orchestration:read"], + ]); + assert.deepStrictEqual(revoked, ["session-1", "session-2", "session-3"]); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts new file mode 100644 index 000000000000..3124e104687f --- /dev/null +++ b/apps/server/src/cli/thread.ts @@ -0,0 +1,741 @@ +import { + AuthOrchestrationReadScope, + EnvironmentHttpApi, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ThreadActionResume from "../orchestration/ThreadActionResume.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import { layerReadOnlyConfig as SqlitePersistenceLayerReadOnly } from "../persistence/Layers/Sqlite.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { type CliAuthLocationFlags, resolveThreadInspectionConfig } from "./config.ts"; + +export const THREAD_READ_DEFAULT_TURN_LIMIT = 5; +export const THREAD_READ_MAX_TURN_LIMIT = 20; +export const THREAD_LIST_MAX_RESULTS = 50; +export const THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS = 20; +export const THREAD_TRANSCRIPT_MAX_CHARS = 64_000; +export const THREAD_ACTIVITY_MAX_RESULTS = 200; + +export class ThreadCliError extends Schema.TaggedErrorClass()("ThreadCliError", { + operation: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `LastCode thread ${this.operation} failed.`; + } +} + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const isThreadCliError = Schema.is(ThreadCliError); + +const ThreadIdentity = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, +}); +const ThreadProject = Schema.Struct({ + id: Schema.String, + title: Schema.String, + workspaceRoot: Schema.String, +}); +const ThreadWorkspace = Schema.Struct({ + root: Schema.String, + branch: Schema.NullOr(Schema.String), +}); +const ThreadProvider = Schema.Struct({ + name: Schema.NullOr(Schema.String), + instanceId: Schema.optional(Schema.String), + status: Schema.NullOr(Schema.String), + codexThreadId: Schema.optional(Schema.String), +}); + +export const ThreadCurrentResult = Schema.Struct({ + kind: Schema.Literal("current"), + ...ThreadIdentity.fields, + home: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, +}); + +export const ThreadListResult = Schema.Struct({ + kind: Schema.Literal("list"), + environmentId: Schema.String, + threadsTruncated: Schema.optional(Schema.Boolean), + originalThreadCount: Schema.optional(Schema.Number), + threads: Schema.Array( + Schema.Struct({ + ...ThreadIdentity.fields, + title: Schema.String, + lifecycle: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, + updatedAt: Schema.String, + }), + ), +}); + +export const ThreadReadResult = Schema.Struct({ + kind: Schema.Literal("read"), + ...ThreadIdentity.fields, + title: Schema.String, + lifecycle: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, + latestTurn: Schema.Unknown, + messages: Schema.Array( + Schema.Struct({ + id: Schema.String, + role: Schema.String, + text: Schema.String, + turnId: Schema.NullOr(Schema.String), + streaming: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, + }), + ), + activities: Schema.Array( + Schema.Struct({ + id: Schema.String, + kind: Schema.String, + tone: Schema.String, + summary: Schema.String, + turnId: Schema.NullOr(Schema.String), + createdAt: Schema.String, + }), + ), + textTruncated: Schema.Boolean, + originalTextChars: Schema.optional(Schema.Number), + activitiesTruncated: Schema.Boolean, + originalActivityCount: Schema.optional(Schema.Number), +}); +const decodeThreadCurrentResult = Schema.decodeUnknownEffect(ThreadCurrentResult); +const decodeThreadListResult = Schema.decodeUnknownEffect(ThreadListResult); +const decodeThreadReadResult = Schema.decodeUnknownEffect(ThreadReadResult); + +export type ThreadTargetResolution = + | { readonly kind: "resolved"; readonly thread: OrchestrationThreadShell } + | { + readonly kind: "ambiguous"; + readonly identifier: string; + readonly candidates: string[]; + readonly candidatesTruncated?: boolean; + readonly originalCandidateCount?: number; + } + | { readonly kind: "not-found"; readonly identifier: string }; + +export function resolveThreadTarget( + threads: ReadonlyArray, + identifier: string, +): ThreadTargetResolution { + const normalized = identifier.trim(); + if (normalized.length === 0) return { kind: "not-found", identifier: normalized }; + const exact = threads.find((thread) => thread.id === normalized); + if (exact) return { kind: "resolved", thread: exact }; + const matches = threads.filter((thread) => thread.id.startsWith(normalized)); + if (matches.length === 1) return { kind: "resolved", thread: matches[0]! }; + if (matches.length > 1) { + const candidates = matches + .map(({ id }) => id) + .toSorted() + .slice(0, THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS); + const candidatesTruncated = matches.length > candidates.length; + return { + kind: "ambiguous", + identifier: normalized, + candidates, + ...(candidatesTruncated + ? { candidatesTruncated: true, originalCandidateCount: matches.length } + : {}), + }; + } + return { kind: "not-found", identifier: normalized }; +} + +export function validateThreadTurnLimit(value: number): number { + if (!Number.isInteger(value) || value < 1 || value > THREAD_READ_MAX_TURN_LIMIT) { + throw new Error(`--turn-limit must be an integer from 1 to ${THREAD_READ_MAX_TURN_LIMIT}.`); + } + return value; +} + +const requestIdForActivity = (activity: OrchestrationThread["activities"][number]) => { + if (typeof activity.payload !== "object" || activity.payload === null) return null; + const requestId = (activity.payload as Record).requestId; + return typeof requestId === "string" ? requestId : null; +}; + +const isStaleRequestFailure = (activity: OrchestrationThread["activities"][number]) => { + if ( + activity.kind !== "provider.approval.respond.failed" && + activity.kind !== "provider.user-input.respond.failed" + ) { + return false; + } + if (typeof activity.payload !== "object" || activity.payload === null) return false; + const detail = (activity.payload as Record).detail; + if (typeof detail !== "string") return false; + const normalized = detail.toLowerCase(); + return ( + normalized.includes("stale pending approval request") || + normalized.includes("unknown pending approval request") || + normalized.includes("unknown pending permission request") || + normalized.includes("stale pending user-input request") || + normalized.includes("unknown pending user-input request") || + normalized.includes("unknown pending user input request") || + normalized.includes("unknown pending codex user input request") + ); +}; + +const pinnedRequestActivityIndexes = (activities: OrchestrationThread["activities"]) => { + const openRequests = new Map(); + for (const [index, activity] of activities.entries()) { + const requestId = requestIdForActivity(activity); + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequests.set(requestId, index); + } else if ( + activity.kind === "approval.resolved" || + activity.kind === "user-input.resolved" || + isStaleRequestFailure(activity) + ) { + openRequests.delete(requestId); + } + } + return new Set(openRequests.values()); +}; + +export function boundThreadPresentation( + messages: OrchestrationThread["messages"], + activities: OrchestrationThread["activities"], +) { + const pinnedActivityIndexes = pinnedRequestActivityIndexes(activities); + const rankedActivities = activities + .map((activity, index) => ({ index, createdAt: activity.createdAt })) + .toSorted( + (left, right) => right.createdAt.localeCompare(left.createdAt) || right.index - left.index, + ); + const rankedPinnedActivities = rankedActivities.filter(({ index }) => + pinnedActivityIndexes.has(index), + ); + const selectedActivityIndexes = new Set( + [ + ...rankedPinnedActivities, + ...rankedActivities.filter(({ index }) => !pinnedActivityIndexes.has(index)), + ] + .slice(0, THREAD_ACTIVITY_MAX_RESULTS) + .map(({ index }) => index), + ); + const selectedActivities = activities + .map((activity, originalIndex) => ({ activity, originalIndex })) + .filter(({ originalIndex }) => selectedActivityIndexes.has(originalIndex)); + const originalTextChars = + messages.reduce((total, message) => total + message.text.length, 0) + + activities.reduce((total, activity) => total + activity.summary.length, 0); + const messageChars = messages.map(() => 0); + const activityChars = selectedActivities.map(() => 0); + let remaining = THREAD_TRANSCRIPT_MAX_CHARS; + const content = [ + ...messages.map((message, index) => ({ + kind: "message" as const, + index, + timestamp: message.updatedAt, + length: message.text.length, + pinned: false, + })), + ...selectedActivities.map(({ activity, originalIndex }, index) => ({ + kind: "activity" as const, + index, + timestamp: activity.createdAt, + length: activity.summary.length, + pinned: pinnedActivityIndexes.has(originalIndex), + })), + ].toSorted( + (left, right) => + Number(right.pinned) - Number(left.pinned) || + right.timestamp.localeCompare(left.timestamp) || + (left.kind === right.kind ? right.index - left.index : left.kind === "activity" ? -1 : 1), + ); + for (const item of content) { + const take = Math.min(item.length, remaining); + if (item.kind === "message") messageChars[item.index] = take; + else activityChars[item.index] = take; + remaining -= take; + } + const boundedMessages = messages.map((message, index) => ({ + ...message, + text: message.text.slice(message.text.length - messageChars[index]!), + })); + const boundedActivities = selectedActivities.map(({ activity }, index) => ({ + ...activity, + summary: activity.summary.slice(activity.summary.length - activityChars[index]!), + })); + const emittedTextChars = THREAD_TRANSCRIPT_MAX_CHARS - remaining; + const activitiesTruncated = activities.length > selectedActivities.length; + return { + messages: boundedMessages, + activities: boundedActivities, + textTruncated: originalTextChars > emittedTextChars, + ...(originalTextChars > emittedTextChars ? { originalTextChars } : {}), + activitiesTruncated, + ...(activitiesTruncated ? { originalActivityCount: activities.length } : {}), + }; +} + +export const boundTranscriptMessages = (messages: OrchestrationThread["messages"]) => + boundThreadPresentation(messages, []); + +export function threadLifecycle( + thread: OrchestrationThreadShell, + options: { readonly now: string }, +): string { + if (thread.hasPendingUserInput || thread.hasPendingApprovals) return "pending-input"; + if (thread.snoozedUntil !== null && thread.snoozedUntil !== undefined) { + const wakeAt = Date.parse(thread.snoozedUntil); + const now = Date.parse(options.now); + if (!Number.isNaN(wakeAt) && !Number.isNaN(now) && wakeAt > now) { + const raisedByError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const raisedByCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + if (!raisedByError && !raisedByCompletion) return "snoozed"; + } + } + if ( + thread.latestTurn?.state === "running" || + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.backgroundLiveness === "working" + ) { + return "working"; + } + if (thread.settledOverride === "settled" || thread.settledAt !== null) return "settled"; + return "active"; +} + +function projectForThread( + snapshot: OrchestrationShellSnapshot, + thread: OrchestrationThreadShell, +): OrchestrationProjectShell { + const project = snapshot.projects.find(({ id }) => id === thread.projectId); + if (!project) + throw new Error(`Project '${thread.projectId}' for thread '${thread.id}' was not found.`); + return project; +} + +function projectOutput(project: OrchestrationProjectShell) { + return { id: project.id, title: project.title, workspaceRoot: project.workspaceRoot }; +} + +function workspaceOutput(project: OrchestrationProjectShell, thread: OrchestrationThreadShell) { + return { root: thread.worktreePath ?? project.workspaceRoot, branch: thread.branch }; +} + +function providerOutput(thread: OrchestrationThreadShell) { + const session = thread.session; + return { + name: session?.providerName ?? null, + ...(session?.providerInstanceId ? { instanceId: session.providerInstanceId } : {}), + status: session?.status ?? null, + ...(session?.providerName === "codex" && session.providerThreadId + ? { codexThreadId: session.providerThreadId } + : {}), + }; +} + +export interface ThreadReadSource { + readonly descriptor: ExecutionEnvironmentDescriptor; + readonly home: string; + readonly shell: OrchestrationShellSnapshot; + readonly getThread: ( + threadId: ThreadId, + turnLimit: number, + ) => Effect.Effect; +} + +export const currentThreadOutput = Effect.fn("currentThreadOutput")(function* ( + source: ThreadReadSource, + context: { readonly threadId?: string; readonly home?: string } = { + ...(process.env.T3CODE_THREAD_ID !== undefined + ? { threadId: process.env.T3CODE_THREAD_ID } + : {}), + ...(process.env.T3CODE_HOME !== undefined ? { home: process.env.T3CODE_HOME } : {}), + }, +) { + const currentId = context.threadId?.trim(); + if (!currentId) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error("Current LastCode thread context is unavailable."), + }); + } + const contextHome = context.home?.trim(); + if (contextHome && contextHome !== source.home) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error(`Current LastCode home '${contextHome}' does not match '${source.home}'.`), + }); + } + const target = source.shell.threads.find(({ id }) => id === currentId); + if (!target) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error(`Current LastCode thread '${currentId}' was not found.`), + }); + } + const project = projectForThread(source.shell, target); + return yield* decodeThreadCurrentResult({ + kind: "current", + environmentId: source.descriptor.environmentId, + threadId: target.id, + home: source.home, + project: projectOutput(project), + workspace: workspaceOutput(project, target), + provider: providerOutput(target), + }); +}); + +export const listThreadsOutput = Effect.fn("listThreadsOutput")(function* ( + source: ThreadReadSource, +) { + const now = DateTime.formatIso(yield* DateTime.now); + const sortedThreads = source.shell.threads.toSorted( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const threadsTruncated = sortedThreads.length > THREAD_LIST_MAX_RESULTS; + return yield* decodeThreadListResult({ + kind: "list", + environmentId: source.descriptor.environmentId, + ...(threadsTruncated + ? { threadsTruncated: true, originalThreadCount: sortedThreads.length } + : {}), + threads: sortedThreads.slice(0, THREAD_LIST_MAX_RESULTS).map((thread) => { + const project = projectForThread(source.shell, thread); + return { + environmentId: source.descriptor.environmentId, + threadId: thread.id, + title: thread.title, + lifecycle: threadLifecycle(thread, { now }), + project: projectOutput(project), + workspace: workspaceOutput(project, thread), + provider: providerOutput(thread), + updatedAt: thread.updatedAt, + }; + }), + }); +}); + +export const readThreadOutput = Effect.fn("readThreadOutput")(function* ( + source: ThreadReadSource, + identifier: string, + turnLimitInput: number, +) { + const resolution = resolveThreadTarget(source.shell.threads, identifier); + if (resolution.kind !== "resolved") { + return { ...resolution, environmentId: source.descriptor.environmentId }; + } + const turnLimit = validateThreadTurnLimit(turnLimitInput); + const now = DateTime.formatIso(yield* DateTime.now); + const detail = yield* source.getThread(resolution.thread.id, turnLimit); + const project = projectForThread(source.shell, resolution.thread); + const presentation = boundThreadPresentation(detail.thread.messages, detail.thread.activities); + return yield* decodeThreadReadResult({ + kind: "read", + environmentId: source.descriptor.environmentId, + threadId: resolution.thread.id, + title: resolution.thread.title, + lifecycle: threadLifecycle(resolution.thread, { now }), + project: projectOutput(project), + workspace: workspaceOutput(project, resolution.thread), + provider: providerOutput(resolution.thread), + latestTurn: detail.thread.latestTurn, + messages: presentation.messages, + activities: presentation.activities.map(({ id, kind, tone, summary, turnId, createdAt }) => ({ + id, + kind, + tone, + summary, + turnId, + createdAt, + })), + textTruncated: presentation.textTruncated, + ...(presentation.originalTextChars !== undefined + ? { originalTextChars: presentation.originalTextChars } + : {}), + activitiesTruncated: presentation.activitiesTruncated, + ...(presentation.originalActivityCount !== undefined + ? { originalActivityCount: presentation.originalActivityCount } + : {}), + }); +}); + +export const ThreadCliOfflineRuntimeLive = Layer.mergeAll( + WorkspacePaths.layer, + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadActionResume.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceLayerReadOnly), + ), +); + +const THREAD_CLI_LIVE_TIMEOUT = Duration.seconds(3); +const makeLiveClient = (origin: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }); + +const readEnvironmentId = Effect.fn("readThreadCliEnvironmentId")(function* ( + config: ServerConfig.ServerConfig["Service"], +) { + const fileSystem = yield* FileSystem.FileSystem; + const value = (yield* fileSystem.readFileString(config.environmentIdPath)).trim(); + if (value.length === 0) { + return yield* new ThreadCliError({ + operation: "environment identity read", + cause: new Error("The active home has no environment identity."), + }); + } + return EnvironmentId.make(value); +}); + +export const withReadSession = ( + auth: EnvironmentAuth.EnvironmentAuth["Service"], + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + auth.issueSession({ scopes: [AuthOrchestrationReadScope], label: "lastcode thread cli" }), + ({ token }) => run(token), + ({ sessionId }) => auth.revokeSession(sessionId).pipe(Effect.ignore({ log: true })), + ); + +const tryRunLiveThreadRead = Effect.fn("tryRunLiveThreadRead")(function* ( + config: ServerConfig.ServerConfig["Service"], + minimumLogLevel: ServerConfig.ServerConfig["Service"]["logLevel"], + run: (source: ThreadReadSource) => Effect.Effect, +) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) return Option.none(); + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptorResult = yield* Effect.result( + client.metadata.descriptor().pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + if (descriptorResult._tag === "Failure") return Option.none(); + const attempted = yield* Effect.result( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* withReadSession(auth, (token) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${token}` }; + const sourceResult = yield* Effect.result( + client.orchestration + .shellSnapshot({ headers }) + .pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + if (sourceResult._tag === "Failure") { + return { kind: "unavailable" as const }; + } + const output = yield* Effect.result( + run({ + descriptor: descriptorResult.success, + home: config.baseDir, + shell: sourceResult.success, + getThread: (threadId, turnLimit) => + client.orchestration + .threadSnapshot({ params: { threadId }, payload: { turnLimit }, headers }) + .pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError( + (cause) => new ThreadCliError({ operation: "live detail read", cause }), + ), + ), + }), + ); + return { kind: "ran" as const, output }; + }).pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ), + ); + if (attempted._tag === "Success" && attempted.success.kind === "ran") { + if (attempted.success.output._tag === "Failure") return yield* attempted.success.output.failure; + return Option.some(attempted.success.output.success); + } + return Option.none(); +}); + +const runThreadRead = Effect.fn("runThreadRead")(function* ( + flags: CliAuthLocationFlags, + run: (source: ThreadReadSource) => Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + return yield* Effect.gen(function* () { + const live = yield* tryRunLiveThreadRead(config, minimumLogLevel, run).pipe( + Effect.provide(FetchHttpClient.layer), + ); + if (Option.isSome(live)) { + return yield* Console.log(yield* encodeJson(live.value)); + } + + const offlineLayer = ThreadCliOfflineRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + return yield* Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getShellSnapshot(); + const environmentId = yield* readEnvironmentId(config); + const source: ThreadReadSource = { + descriptor: { + environmentId, + label: "offline", + platform: { os: "unknown", arch: "other" }, + serverVersion: "offline", + capabilities: { repositoryIdentity: true }, + }, + home: config.baseDir, + shell, + getThread: (threadId, turnLimit) => + query.getThreadDetailSnapshot(threadId, { turnLimit }).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new ThreadCliError({ + operation: "offline detail read", + cause: new Error(`Thread '${threadId}' was not found.`), + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "offline detail read", cause }), + ), + ), + }; + const output = yield* run(source); + yield* Console.log(yield* encodeJson(output)); + }).pipe(Effect.provide(offlineLayer)); + }); +}); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Print stable JSON output."), + Flag.withDefault(false), +); + +const threadLocationFlags = { + baseDir: Flag.string("base-dir").pipe(Flag.optional), + stateDir: Flag.string("state-dir").pipe( + Flag.withDescription("Explicit active state directory (used by the generated wrapper)."), + Flag.optional, + ), +} as const; + +const currentCommand = Command.make("current", { + ...threadLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("Identify the current LastCode thread."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + currentThreadOutput(source).pipe( + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "current output encoding", cause }), + ), + ), + ), + ), +); + +const listCommand = Command.make("list", { + ...threadLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active threads in this LastCode environment."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + listThreadsOutput(source).pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "list output encoding", cause }), + ), + ), + ), + ), +); + +const readCommand = Command.make("read", { + ...threadLocationFlags, + json: jsonFlag, + thread: Argument.string("thread").pipe( + Argument.withDescription("Exact LastCode thread id or unambiguous id prefix."), + ), + turnLimit: Flag.integer("turn-limit").pipe( + Flag.withDescription(`Recent user-turn window (1-${THREAD_READ_MAX_TURN_LIMIT}).`), + Flag.withDefault(THREAD_READ_DEFAULT_TURN_LIMIT), + ), +}).pipe( + Command.withDescription("Read a bounded recent transcript for one thread."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + readThreadOutput(source, flags.thread, flags.turnLimit).pipe( + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "read output encoding", cause }), + ), + ), + ), + ), +); + +export const threadCommand = Command.make("thread").pipe( + Command.withDescription("Inspect LastCode threads."), + Command.withSubcommands([currentCommand, listCommand, readCommand]), +); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cfc36b15cfc2..7d6f1f64bf84 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1163,6 +1163,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti status: event.payload.session.status, providerName: event.payload.session.providerName, providerInstanceId: event.payload.session.providerInstanceId ?? null, + providerThreadId: event.payload.session.providerThreadId ?? null, runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 1fd65c2969cd..9cad0607b1b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -469,6 +469,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", + providerThreadId: "provider-thread-1", runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, @@ -486,7 +487,16 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); assert.equal(threadDetail._tag, "Some"); if (threadDetail._tag === "Some") { - assert.deepEqual(threadDetail.value, snapshot.threads[0]); + const snapshotThread = snapshot.threads[0]; + assert.ok(snapshotThread); + assert.ok(snapshotThread.session); + assert.deepEqual(threadDetail.value, { + ...snapshotThread, + session: { + ...snapshotThread.session, + providerThreadId: "provider-thread-1", + }, + }); } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e0313a3d9691..42db0c4e2c5d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -306,6 +306,7 @@ function mapSessionRow( status: row.status, providerName: row.providerName, ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), + providerThreadId: row.providerThreadId, runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, @@ -1071,6 +1072,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", + provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a146480d4392..265393310569 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -40,6 +40,9 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; + +const ProviderThreadResumeCursor = Schema.Struct({ threadId: Schema.String }); +const isProviderThreadResumeCursor = Schema.is(ProviderThreadResumeCursor); import { forkParked, ServerActivation } from "../../serverActivation.ts"; import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { @@ -642,6 +645,9 @@ const make = Effect.gen(function* () { : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, + ...(session.provider === "codex" && isProviderThreadResumeCursor(session.resumeCursor) + ? { providerThreadId: session.resumeCursor.threadId } + : {}), runtimeMode: desiredRuntimeMode, // Provider turn ids are not orchestration turn ids. activeTurnId: null, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..4a3a250bb256 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -321,6 +321,7 @@ describe("ProviderRuntimeIngestion", () => { engine, dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readShell: () => Effect.runPromise(snapshotQuery.getShellSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, drain, @@ -634,6 +635,7 @@ describe("ProviderRuntimeIngestion", () => { threadId, status: "starting", providerName: "codex", + providerThreadId: "codex-native-stopped", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -649,6 +651,7 @@ describe("ProviderRuntimeIngestion", () => { threadId, status: "stopped", providerName: "codex", + providerThreadId: "codex-native-stopped", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -740,6 +743,36 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("preserves thread.started native identity through later lifecycle events", async () => { + const harness = await createHarness(); + harness.emit({ + type: "thread.started", + eventId: asEventId("evt-native-thread-started"), + provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), + payload: { providerThreadId: "codex-native-lifecycle" }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + await waitForThread( + harness.readShell as never, + (thread) => thread.session?.providerThreadId === "codex-native-lifecycle", + ); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-native-turn-started"), + provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-native-lifecycle"), + createdAt: "2026-01-01T00:00:02.000Z", + }); + await waitForThread( + harness.readShell as never, + (thread) => + thread.session?.status === "running" && + thread.session.providerThreadId === "codex-native-lifecycle", + ); + }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); const seededAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..d0f70f7edf1d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1646,6 +1646,9 @@ const make = Effect.gen(function* () { ...(event.providerInstanceId !== undefined ? { providerInstanceId: event.providerInstanceId } : {}), + ...(event.type === "thread.started" && event.payload?.providerThreadId !== undefined + ? { providerThreadId: event.payload.providerThreadId } + : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, diff --git a/apps/server/src/orchestration/decider.session.test.ts b/apps/server/src/orchestration/decider.session.test.ts new file mode 100644 index 000000000000..116081a26d40 --- /dev/null +++ b/apps/server/src/orchestration/decider.session.test.ts @@ -0,0 +1,128 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-session-identity"); +const previousSession: OrchestrationSession = { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex-old"), + providerThreadId: "codex-native-old", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, +}; + +const readModel: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: threadId, + projectId: ProjectId.make("project-session-identity"), + title: "Session identity", + modelSelection: { instanceId: ProviderInstanceId.make("codex-old"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: previousSession, + }, + ], + updatedAt: now, +}; + +const decideSession = (session: OrchestrationSession) => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-${session.providerName}-${session.status}`), + threadId, + session, + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(decided) ? decided[0] : decided; + if (event?.type !== "thread.session-set") throw new Error("Expected thread.session-set"); + return event.payload.session; + }); + +const incoming = (overrides: Partial = {}): OrchestrationSession => ({ + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + ...overrides, +}); + +it.layer(NodeServices.layer)("thread session identity decider", (it) => { + it.effect("preserves only omitted identity fields on the same binding", () => + Effect.gen(function* () { + const sameBinding = yield* decideSession( + incoming({ providerInstanceId: ProviderInstanceId.make("codex-old") }), + ); + expect(sameBinding.providerThreadId).toBe("codex-native-old"); + + const missingInstance = yield* decideSession(incoming()); + expect(missingInstance.providerInstanceId).toBe("codex-old"); + expect(missingInstance.providerThreadId).toBe("codex-native-old"); + + const explicitlyCleared = yield* decideSession( + incoming({ + providerInstanceId: ProviderInstanceId.make("codex-old"), + providerThreadId: null, + }), + ); + expect(explicitlyCleared.providerThreadId).toBeNull(); + }), + ); + + it.effect("clears native identity when provider or provider instance changes", () => + Effect.gen(function* () { + const providerChanged = yield* decideSession( + incoming({ + providerName: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-new"), + }), + ); + expect(providerChanged.providerThreadId).toBeNull(); + + const instanceChanged = yield* decideSession( + incoming({ providerInstanceId: ProviderInstanceId.make("codex-new") }), + ); + expect(instanceChanged.providerThreadId).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d61d0bff9308..8090d7471c07 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1311,6 +1311,26 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const previousSession = thread.session; + const sameProvider = previousSession?.providerName === command.session.providerName; + const sameBinding = + sameProvider && + (command.session.providerInstanceId === undefined || + command.session.providerInstanceId === previousSession?.providerInstanceId); + const providerThreadIdWasSupplied = Object.hasOwn(command.session, "providerThreadId"); + const session = { + ...command.session, + ...(sameProvider && + command.session.providerInstanceId === undefined && + previousSession?.providerInstanceId !== undefined + ? { providerInstanceId: previousSession.providerInstanceId } + : {}), + providerThreadId: providerThreadIdWasSupplied + ? (command.session.providerThreadId ?? null) + : sameBinding + ? (previousSession?.providerThreadId ?? null) + : null, + }; const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1322,7 +1342,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.session-set", payload: { threadId: command.threadId, - session: command.session, + session, }, }; // Only a session coming alive is activity worth waking a settled thread diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index dcb750983a00..8e864ff7079a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -25,6 +25,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status, provider_name, provider_instance_id, + provider_thread_id, runtime_mode, active_turn_id, last_error, @@ -35,6 +36,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.status}, ${row.providerName}, ${row.providerInstanceId}, + ${row.providerThreadId ?? null}, ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, @@ -45,6 +47,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status = excluded.status, provider_name = excluded.provider_name, provider_instance_id = excluded.provider_instance_id, + provider_thread_id = excluded.provider_thread_id, runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, @@ -62,6 +65,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", + provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ec1ffdefac0f..d633bc0b5f89 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -10,6 +10,10 @@ import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; + readonly readonly?: boolean; + readonly create?: boolean; + readonly readwrite?: boolean; + readonly disableWAL?: boolean; readonly spanAttributes?: Record; }; @@ -60,6 +64,23 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( ); }, Layer.unwrap); +export const makeSqlitePersistenceReadOnly = Effect.fn("makeSqlitePersistenceReadOnly")(function* ( + dbPath: string, +) { + const path = yield* Path.Path; + return makeRuntimeSqliteLayer({ + filename: dbPath, + readonly: true, + readwrite: false, + create: false, + disableWAL: true, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3-server", + }, + }); +}, Layer.unwrap); + export const SqlitePersistenceMemory = Layer.provideMerge( setup, makeRuntimeSqliteLayer({ filename: ":memory:" }), @@ -71,3 +92,10 @@ export const layerConfig = Layer.unwrap( return makeSqlitePersistenceLive(dbPath); }), ); + +export const layerReadOnlyConfig = Layer.unwrap( + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig; + return makeSqlitePersistenceReadOnly(dbPath); + }), +); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7cecac33eb6a..d11feb035036 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -26,6 +26,7 @@ export const ProjectionThreadSession = Schema.Struct({ status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), providerInstanceId: Schema.NullOr(ProviderInstanceId), + providerThreadId: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), diff --git a/apps/server/src/provider/CodexThreadTool.test.ts b/apps/server/src/provider/CodexThreadTool.test.ts new file mode 100644 index 000000000000..c9fe0ce97078 --- /dev/null +++ b/apps/server/src/provider/CodexThreadTool.test.ts @@ -0,0 +1,212 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { + canExposeCodexThreadTool, + materializeCodexThreadTool, + renderCodexThreadToolWrapper, +} from "./CodexThreadTool.ts"; + +it("exposes persistent wrappers only on supported host environments", () => { + assert.isTrue(canExposeCodexThreadTool("linux", { PATH: "/usr/bin" })); + assert.isTrue( + canExposeCodexThreadTool("darwin", { + ELECTRON_RUN_AS_NODE: "1", + PATH: "/usr/bin", + }), + ); + assert.isFalse( + canExposeCodexThreadTool("linux", { + APPIMAGE: "/tmp/.mount_LastCode/LastCode.AppImage", + }), + ); + assert.isFalse(canExposeCodexThreadTool("linux", { APPDIR: "/tmp/.mount_LastCode" })); + assert.isTrue(canExposeCodexThreadTool("linux", { APPIMAGE: "", APPDIR: " " })); + assert.isFalse(canExposeCodexThreadTool("win32", {})); +}); + +it("renders an ordinary Node-hosted wrapper pinned to its owning home", () => { + assert.strictEqual( + renderCodexThreadToolWrapper({ + executablePath: "/opt/node/bin/node", + cliEntryPath: "/opt/t3/dist/bin.mjs", + baseDir: "/srv/lastcode home", + stateDir: "/srv/lastcode home/userdata", + electronRunAsNode: false, + }), + "#!/bin/sh\ncase \"$1\" in\n current|list|read) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", + ); +}); + +it("renders a packaged POSIX Electron wrapper with Node mode preserved", () => { + const wrapper = renderCodexThreadToolWrapper({ + executablePath: "/opt/LastCode/lastcode", + cliEntryPath: "/opt/LastCode/resources/app.asar/apps/server/dist/bin.mjs", + baseDir: "/home/me/.lastcode", + stateDir: "/home/me/.lastcode/dev", + electronRunAsNode: true, + }); + assert.match(wrapper, /^#!\/bin\/sh\nexport ELECTRON_RUN_AS_NODE=1\n/); + assert.match( + wrapper, + /thread "\$command" --base-dir '\/home\/me\/\.lastcode' --state-dir '\/home\/me\/\.lastcode\/dev'/, + ); +}); + +it.effect("materializes an executable wrapper under the active state directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "lastcode-thread-tool-" }); + const stateDir = NodePath.join(baseDir, "userdata"); + const result = yield* materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: "/usr/bin/node", + cliEntryPath: "/app/bin.mjs", + electronRunAsNode: "0", + }); + const stat = yield* Effect.promise(() => NodeFSP.stat(result.wrapperPath)); + const wrapper = yield* fileSystem.readFileString(result.wrapperPath); + assert.strictEqual(result.wrapperPath, NodePath.join(stateDir, "bin", "lastcode-thread")); + assert.ok((stat.mode & 0o111) !== 0); + assert.isFalse(wrapper.includes("ELECTRON_RUN_AS_NODE")); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("atomically publishes concurrent wrapper materializations", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-concurrent-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const inputA = { + stateDir, + baseDir, + executablePath: "/opt/node-a/bin/node", + cliEntryPath: "/opt/t3-a/dist/bin.mjs", + electronRunAsNode: "0", + } as const; + const inputB = { + stateDir, + baseDir, + executablePath: "/opt/node-b/bin/node", + cliEntryPath: "/opt/t3-b/dist/bin.mjs", + electronRunAsNode: "1", + } as const; + const [result] = yield* Effect.all( + [materializeCodexThreadTool(inputA), materializeCodexThreadTool(inputB)], + { concurrency: "unbounded" }, + ); + const finalContents = yield* fileSystem.readFileString(result.wrapperPath); + const expectedContents = [ + renderCodexThreadToolWrapper({ + ...inputA, + electronRunAsNode: false, + }), + renderCodexThreadToolWrapper({ + ...inputB, + electronRunAsNode: true, + }), + ]; + + assert.isTrue(expectedContents.includes(finalContents)); + assert.deepStrictEqual(yield* fileSystem.readDirectory(result.binDir), ["lastcode-thread"]); + const stat = yield* Effect.promise(() => NodeFSP.stat(result.wrapperPath)); + assert.ok((stat.mode & 0o111) !== 0); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("cleans its temporary sibling when atomic publication fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-publication-failure-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const binDir = NodePath.join(stateDir, "bin"); + const wrapperPath = NodePath.join(binDir, "lastcode-thread"); + yield* fileSystem.makeDirectory(wrapperPath, { recursive: true }); + + const result = yield* Effect.result( + materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: "/opt/node/bin/node", + cliEntryPath: "/opt/t3/dist/bin.mjs", + electronRunAsNode: "0", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(yield* fileSystem.readDirectory(binDir), ["lastcode-thread"]); + assert.isTrue((yield* fileSystem.stat(wrapperPath)).type === "Directory"); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("preserves inherited Electron Node mode in a Linux wrapper", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-linux-electron-", + }); + const result = yield* materializeCodexThreadTool({ + stateDir: NodePath.join(baseDir, "userdata"), + baseDir, + executablePath: "/opt/LastCode/lastcode", + cliEntryPath: "/opt/LastCode/resources/app.asar/apps/server/dist/bin.mjs", + electronRunAsNode: "1", + }); + + assert.match( + yield* fileSystem.readFileString(result.wrapperPath), + /^#!\/bin\/sh\nexport ELECTRON_RUN_AS_NODE=1\n/, + ); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("routes pinned flags through each real thread leaf parser", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-parser-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const result = yield* materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: process.execPath, + cliEntryPath: NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../bin.ts", + ), + electronRunAsNode: "0", + }); + for (const command of ["current", "list", "read"] as const) { + const output = yield* Effect.tryPromise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile(result.wrapperPath, [command, "--help"], (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); + }), + ); + assert.match(output, new RegExp(`t3 thread ${command}`)); + } + const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["send"], { + encoding: "utf8", + }); + assert.strictEqual(unsupported.status, 64); + assert.match(unsupported.stderr, /unsupported command 'send'/); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); diff --git a/apps/server/src/provider/CodexThreadTool.ts b/apps/server/src/provider/CodexThreadTool.ts new file mode 100644 index 000000000000..f1dc47d049bf --- /dev/null +++ b/apps/server/src/provider/CodexThreadTool.ts @@ -0,0 +1,85 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +export class CodexThreadToolError extends Schema.TaggedErrorClass()( + "CodexThreadToolError", + { cause: Schema.Defect() }, +) {} + +export interface CodexThreadToolInvocation { + readonly executablePath: string; + readonly cliEntryPath: string; + readonly baseDir: string; + readonly stateDir: string; + readonly electronRunAsNode: boolean; +} + +const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + +export function canExposeCodexThreadTool( + platform: NodeJS.Platform, + environment: Readonly>, +): boolean { + const isAppImage = + platform === "linux" && + [environment.APPIMAGE, environment.APPDIR].some( + (value) => value !== undefined && value.trim().length > 0, + ); + return platform !== "win32" && !isAppImage; +} + +export function renderCodexThreadToolWrapper(input: CodexThreadToolInvocation): string { + const executable = [ + shellQuote(input.executablePath), + shellQuote(input.cliEntryPath), + "thread", + ].join(" "); + const pinnedFlags = `--base-dir ${shellQuote(input.baseDir)} --state-dir ${shellQuote(input.stateDir)}`; + return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; +} + +export const materializeCodexThreadTool = Effect.fn("materializeCodexThreadTool")( + function* (input: { + readonly stateDir: string; + readonly baseDir: string; + readonly executablePath?: string; + readonly cliEntryPath?: string; + readonly electronRunAsNode?: string; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = input.executablePath ?? process.execPath; + const cliEntryPath = input.cliEntryPath ?? process.argv[1]; + if (cliEntryPath === undefined || cliEntryPath.trim().length === 0) { + return yield* new CodexThreadToolError({ + cause: new Error("The server CLI entry path is unavailable."), + }); + } + + const binDir = path.join(input.stateDir, "bin"); + const wrapperPath = path.join(binDir, "lastcode-thread"); + const wrapperContents = renderCodexThreadToolWrapper({ + executablePath, + cliEntryPath, + baseDir: input.baseDir, + stateDir: input.stateDir, + electronRunAsNode: (input.electronRunAsNode ?? process.env.ELECTRON_RUN_AS_NODE) === "1", + }); + yield* Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.makeDirectory(binDir, { recursive: true }); + const temporaryPath = yield* fileSystem.makeTempFileScoped({ + directory: binDir, + prefix: ".lastcode-thread.", + suffix: ".tmp", + }); + yield* fileSystem.writeFileString(temporaryPath, wrapperContents); + yield* fileSystem.chmod(temporaryPath, 0o755); + yield* fileSystem.rename(temporaryPath, wrapperPath); + }), + ).pipe(Effect.mapError((cause) => new CodexThreadToolError({ cause }))); + return { binDir, wrapperPath } as const; + }, +); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 15aceec5e83f..871673faab85 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -19,6 +19,7 @@ import { TurnId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; @@ -285,7 +286,10 @@ validationLayer("CodexAdapterLive validation", (it) => { runtimeMode: "full-access", }); - NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { + const runtimeOptions = validationRuntimeFactory.factory.mock.calls[0]?.[0]; + NodeAssert.ok(runtimeOptions); + const { environment: _environment, ...optionsWithoutEnvironment } = runtimeOptions; + NodeAssert.deepStrictEqual(optionsWithoutEnvironment, { binaryPath: "codex", cwd: process.cwd(), launchArgs: "", @@ -468,6 +472,118 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }).pipe(Effect.provide(layer)); }); + it.effect("injects LastCode identity and the active-home thread wrapper into Codex only", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: "/usr/bin" }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-thread-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-thread-id"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-thread-id"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal( + runtime.options.environment?.PATH, + `${NodePath.join(config.stateDir, "bin")}:/usr/bin`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("injects LastCode identity without a POSIX wrapper on Windows", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: "C:\\Windows\\System32" }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-windows-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "win32")), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-windows-thread"), + runtimeMode: "full-access", + }); + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-windows-thread"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal(runtime.options.environment?.PATH, "C:\\Windows\\System32"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("injects LastCode identity without a transient AppImage wrapper", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { + APPIMAGE: "/tmp/.mount_LastCode/LastCode.AppImage", + APPDIR: "/tmp/.mount_LastCode", + PATH: "/usr/bin", + }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-appimage-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-appimage-thread"), + runtimeMode: "full-access", + }); + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-appimage-thread"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal(runtime.options.environment?.PATH, "/usr/bin"); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5e8810b41a61..e8a9f1e65b76 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -32,6 +32,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -40,6 +41,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -54,6 +56,7 @@ import { import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { canExposeCodexThreadTool, materializeCodexThreadTool } from "../CodexThreadTool.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -1627,6 +1630,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostProcessPlatform = yield* HostProcessPlatform; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const serverConfig = yield* Effect.service(ServerConfig); @@ -1663,13 +1668,45 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const runtimeEnvironment = options?.environment ?? process.env; + const threadTool = !canExposeCodexThreadTool(hostProcessPlatform, runtimeEnvironment) + ? null + : options?.makeRuntime + ? { binDir: path.join(serverConfig.stateDir, "bin") } + : yield* materializeCodexThreadTool({ + stateDir: serverConfig.stateDir, + baseDir: serverConfig.baseDir, + ...(runtimeEnvironment.ELECTRON_RUN_AS_NODE !== undefined + ? { + electronRunAsNode: runtimeEnvironment.ELECTRON_RUN_AS_NODE, + } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to prepare the LastCode thread command.", + cause, + }), + ), + ); + const codexEnvironment = { + ...runtimeEnvironment, + ...(threadTool ? { PATH: `${threadTool.binDir}:${runtimeEnvironment.PATH ?? ""}` } : {}), + T3CODE_THREAD_ID: input.threadId, + T3CODE_HOME: serverConfig.baseDir, + }; const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + environment: codexEnvironment, ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -1682,7 +1719,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...codexEnvironment, T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ diff --git a/docs/lastcode/codex-thread-tools-plan.md b/docs/lastcode/codex-thread-tools-plan.md index 91572758f9dc..da07b44a5651 100644 --- a/docs/lastcode/codex-thread-tools-plan.md +++ b/docs/lastcode/codex-thread-tools-plan.md @@ -82,10 +82,15 @@ passed back to `wait`, which resumes waiting for that specific message's project provider turn and resulting assistant response. It never interprets an arbitrary newer turn as the answer. -`read` and successful `wait` CLI output share a 64,000-character -transcript/assistant-text presentation budget. JSON includes `textTruncated` and -`originalTextChars` when selected content exceeds that budget; metadata and identifiers -are never truncated. The live server may hydrate its existing bounded-turn detail +`read` and successful `wait` CLI output use a 64,000-character presentation budget. +For `read`, message text and activity summaries share that budget. Unresolved approval and +user-input request explanations are retained first, then the newest content fills the remaining +text budget. Activity records also have a conservative cap: unresolved requests are pinned +first and the remaining slots contain the newest activity, all in their original deterministic +order. JSON includes +`textTruncated` and `originalTextChars` when selected text exceeds the budget, plus +additive activity-count truncation metadata when the record cap applies; metadata and +identifiers are never truncated. The live server may hydrate its existing bounded-turn detail snapshot before the CLI applies this output bound; this temporary local transport does not add a second text-limited SQL/query stack. @@ -143,19 +148,28 @@ Branch: `lastcode/codex-thread-read` 4. Make the wrapper pin its owning home explicitly on every invocation. For an ordinary Node-hosted server it executes that server's runtime and bundled CLI entry with `--base-dir `. For packaged macOS LastCode it executes the LastCode - binary with `ELECTRON_RUN_AS_NODE=1`, the bundled server CLI entry, and the same - explicit base directory. The wrapper contains invocation details only and delegates - all behavior to `t3 thread`. Windows packaged hosts are out of scope. + binary while preserving inherited `ELECTRON_RUN_AS_NODE=1`, the bundled server CLI + entry, and the same explicit base directory. The wrapper contains invocation details + only and delegates all behavior to `t3 thread`. Windows and packaged Linux AppImage + hosts are out of scope for the POSIX wrapper; AppImage executable and app-resource + paths live under a transient mount. Codex still receives LastCode identity variables + on those hosts, but no thread command is added to PATH. 5. Add bounded `list` and `read` commands over the existing shell and thread-detail snapshots. `read` accepts an exact or unambiguous thread-ID prefix and returns - candidates when resolution is ambiguous. + a small deterministic candidate subset plus original-count truncation metadata when + resolution is ambiguous. `list` returns at most 50 deterministically ordered threads + and reports truncation plus the original thread count when that bound is exceeded. 6. Default `read` to a small recent-turn window and impose a conservative maximum. Include thread status, project/workspace/branch, recent turns, and transcript content needed to answer “what is this thread up to?” without dumping the full database. 7. For offline transcript reads, call the bounded thread-detail projection query directly rather than the command read model, which intentionally omits hydrated - thread bodies. + thread bodies. Compose only the SQLite persistence and projection snapshot-query + layers through a read-only database client that skips WAL setup and migrations; + derive the selected home/state paths without provisioning directories or trace files, + and do not probe or reserve a server port. Offline inspection must not start the + writable orchestration engine or its projectors alongside a live server. 8. Preserve lifecycle visibility for active snoozed, settled, pending-input, and working threads. Do not mutate those states. Archived and deleted threads are out of scope and return not found. @@ -402,7 +416,7 @@ the product contract and three slices above are the implementation source of tru thread addressing, specified the wait HTTP/timeout contract, and retained durable message correlation for turns that fail before receiving a provider turn ID. - Round 1 `best-practices`: eight findings applied. The plan now pins the wrapper's - owning home and packaged invocation, limits the first version to packaged macOS, + owning home and packaged invocation, limits the first version to POSIX Node hosts and packaged macOS, uses least-privilege command scopes, bounds send input, carries exact message correlation through provider-start outcomes, rejects overlapping tool sends, uses existing terminal-state vocabulary, and reports pre-adoption restart orphaning diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md new file mode 100644 index 000000000000..35216da12bc6 --- /dev/null +++ b/docs/user/codex-thread-tools.md @@ -0,0 +1,39 @@ +# Codex thread inspection + +On supported hosts, LastCode adds `lastcode-thread` to the `PATH` of Codex sessions it starts. The command identifies +the current LastCode thread and reads active threads owned by the same LastCode environment: + +```sh +lastcode-thread current --json +lastcode-thread list --json +lastcode-thread read --turn-limit 5 --json +``` + +Thread IDs are resolved locally. An exact ID always wins; a prefix must identify exactly one +active thread. Ambiguous JSON responses include a small sorted candidate list and report when +additional matches were omitted. Archived and deleted threads are not included. + +`list` returns at most the 50 most recently updated active threads, with thread ID as +a deterministic tie-breaker. When more exist, its JSON includes `threadsTruncated: true` +and `originalThreadCount`. + +`read` bounds recent message text and activity summaries to one 64,000-character output +budget and caps activity records. Unresolved approval and user-input requests are retained, +with remaining activity slots filled by the newest activity. Its JSON reports when text or +activity counts were truncated. + +The bundled thread command is currently available on POSIX Node hosts and packaged macOS. +Windows and packaged Linux AppImage Codex sessions still receive LastCode thread and home +identity, but do not receive a `lastcode-thread` launcher. Windows has no POSIX launcher; +AppImage executable and resource paths are transient mount paths. + +To inspect a different host, first choose that host with an existing SSH alias and invoke the +wrapper stored in its LastCode home: + +```sh +ssh ~/.lastcode/userdata/bin/lastcode-thread list --json +ssh ~/.lastcode/userdata/bin/lastcode-thread read --json +``` + +`~/.lastcode` is the default home. If that host uses a custom LastCode home, use its explicit +`userdata/bin/lastcode-thread` path. LastCode does not discover hosts or read SSH configuration. diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 3e1b9be0bba5..010c7c0ab182 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -806,6 +806,21 @@ it.effect("decodes orchestration session runtime mode defaults", () => }), ); +it.effect("preserves optional provider-native thread identity", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationSession({ + threadId: "thread-1", + status: "ready", + providerName: "codex", + providerThreadId: "codex-thread-1", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.providerThreadId, "codex-thread-1"); + }), +); + it.effect("defaults proposed plan implementation metadata for historical rows", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationProposedPlan({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6e52521dd6e2..40fa8b03c8f7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -367,6 +367,7 @@ export const OrchestrationSession = Schema.Struct({ status: OrchestrationSessionStatus, providerName: Schema.NullOr(TrimmedNonEmptyString), providerInstanceId: Schema.optional(ProviderInstanceId), + providerThreadId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), From d7902973017c672364be2150be62bc0d94cffc80 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 00:26:33 -0700 Subject: [PATCH 03/23] feat(lastcode): send messages to live threads (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex thread tool could inspect another LastCode thread, but it could not deliver a user-directed message to one. This adds a live-server-only `send` command that resolves an exact or unique thread ID, validates the message against the existing provider limit, and dispatches through LastCode’s authenticated orchestration path using the target thread’s current runtime and interaction settings. It returns an accepted message ID only after dispatch persistence is confirmed, revokes its least-privilege credential on every exit path, and never falls back to offline mutation. Validation: one clean Luna-high review round across correctness, KISS, and best-practices; full required local CI; focused server tests cover live HTTP dispatch, decider rejection, auth cleanup, invalid targets/input, offline immutability, and wrapper routing. Implemented with GPT-5.6 Sol (medium) through the Codex harness. --- apps/server/src/bin.test.ts | 204 ++++++++++++++- apps/server/src/cli/thread.test.ts | 192 ++++++++++++++ apps/server/src/cli/thread.ts | 235 +++++++++++++++++- .../src/provider/CodexThreadTool.test.ts | 8 +- apps/server/src/provider/CodexThreadTool.ts | 2 +- docs/user/codex-thread-tools.md | 18 +- 6 files changed, 647 insertions(+), 12 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 2519b58c44dc..558a595e7b17 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -12,6 +12,7 @@ import { EnvironmentId, EnvironmentMetadataHttpApi, EnvironmentOrchestrationHttpApi, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -21,6 +22,7 @@ import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; @@ -31,7 +33,12 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; -import { ThreadCliOfflineRuntimeLive } from "./cli/thread.ts"; +import { + ThreadCliOfflineRuntimeLive, + ThreadSendMessageError, + ThreadSendServerUnavailableError, + ThreadSendTargetError, +} from "./cli/thread.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -50,6 +57,9 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const isThreadSendMessageError = Schema.is(ThreadSendMessageError); +const isThreadSendServerUnavailableError = Schema.is(ThreadSendServerUnavailableError); +const isThreadSendTargetError = Schema.is(ThreadSendTargetError); class ProjectCliHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentOrchestrationHttpApi) {} @@ -774,6 +784,198 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("sends through the authenticated live route and revokes its CLI session", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-send-live-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-send-live-workspace-"), + ); + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getSnapshot(); + const project = shell.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const threadId = ThreadId.make("thread-send-live-authenticated"); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-live-create"), + threadId, + projectId: project.id, + title: "Live send", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "plan", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const beforeSessions = yield* auth.listSessions(); + const { output } = yield* captureStdout( + runCliWithRuntime([ + "thread", + "send", + "thread-send-live", + "--message", + " Report the current status. ", + "--base-dir", + baseDir, + "--json", + ]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const accepted = JSON.parse(output) as { + readonly kind: string; + readonly environmentId: string; + readonly threadId: string; + readonly messageId: string; + }; + assert.deepStrictEqual( + { + kind: accepted.kind, + environmentId: accepted.environmentId, + threadId: accepted.threadId, + }, + { + kind: "accepted", + environmentId: "env-thread-live", + threadId, + }, + ); + assert.isTrue(accepted.messageId.length > 0); + const detail = yield* query.getThreadDetailSnapshot(threadId); + assert.isTrue(Option.isSome(detail)); + if (Option.isSome(detail)) { + const sent = detail.value.thread.messages.find( + (message) => message.id === accepted.messageId, + ); + assert.equal(sent?.role, "user"); + assert.equal(sent?.text, "Report the current status."); + } + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-live-rival"), + threadId: ThreadId.make("thread-send-live-rival"), + projectId: project.id, + title: "Live send rival", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const ambiguous = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + "thread-send-live", + "--message", + "ambiguous", + "--base-dir", + baseDir, + ]), + ); + const missing = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + "missing-thread", + "--message", + "missing", + "--base-dir", + baseDir, + ]), + ); + const oversized = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + "--base-dir", + baseDir, + ]), + ); + assert.strictEqual(ambiguous._tag, "Failure"); + assert.isTrue(ambiguous._tag === "Failure" && isThreadSendTargetError(ambiguous.failure)); + assert.strictEqual(missing._tag, "Failure"); + assert.isTrue(missing._tag === "Failure" && isThreadSendTargetError(missing.failure)); + assert.strictEqual(oversized._tag, "Failure"); + assert.isTrue( + oversized._tag === "Failure" && isThreadSendMessageError(oversized.failure), + ); + assert.equal((yield* auth.listSessions()).length, beforeSessions.length); + }), + ); + }), + ); + + it.effect("requires a live server for send without mutating offline state", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-send-offline-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-send-offline-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const snapshot = yield* readPersistedSnapshot(baseDir); + const project = snapshot.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const config = yield* makeCliTestServerConfig(baseDir); + const threadId = ThreadId.make("thread-send-offline"); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-offline-create"), + threadId, + projectId: project.id, + title: "Offline send target", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + const databaseStatBefore = NodeFS.statSync(config.dbPath); + + const result = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "must not persist", + "--base-dir", + baseDir, + ]), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(isThreadSendServerUnavailableError(result.failure)); + } + const databaseStatAfter = NodeFS.statSync(config.dbPath); + assert.equal(databaseStatAfter.size, databaseStatBefore.size); + assert.equal(databaseStatAfter.mtimeMs, databaseStatBefore.mtimeMs); + const after = yield* readPersistedSnapshot(baseDir); + assert.deepStrictEqual(after.threads.find((thread) => thread.id === threadId)?.messages, []); + }), + ); + it.effect("rejects dev-url on project commands", () => Effect.gen(function* () { const workspaceRoot = NodeFS.mkdtempSync( diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index e2fceb8171f0..f5720baec934 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -1,28 +1,39 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import { + CommandId, ThreadId, EnvironmentId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type OrchestrationCommand, type OrchestrationMessage, type OrchestrationThread, type OrchestrationThreadShell, } from "@t3tools/contracts"; +import { decideOrchestrationCommand } from "../orchestration/decider.ts"; +import { createEmptyReadModel } from "../orchestration/projector.ts"; import { THREAD_TRANSCRIPT_MAX_CHARS, THREAD_ACTIVITY_MAX_RESULTS, THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS, THREAD_LIST_MAX_RESULTS, type ThreadReadSource, + type ThreadSendSource, + ThreadCliError, boundThreadPresentation, boundTranscriptMessages, currentThreadOutput, listThreadsOutput, readThreadOutput, resolveThreadTarget, + sendThreadOutput, threadLifecycle, validateThreadTurnLimit, withReadSession, + withSendSession, } from "./thread.ts"; const shellThread = (id: string) => ({ id: ThreadId.make(id) }) as OrchestrationThreadShell; @@ -57,6 +68,9 @@ const runnerSource = () => { id: ThreadId.make("thread-runner"), projectId: "project-runner", title: "Runner thread", + modelSelection: { instanceId: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: "plan", updatedAt: "2026-01-02T00:00:00.000Z", branch: "main", worktreePath: null, @@ -534,3 +548,181 @@ it.effect( assert.deepStrictEqual(revoked, ["session-1", "session-2", "session-3"]); }), ); + +it.effect("prepares and dispatches an exact accepted send using the target thread settings", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => { + dispatched.push(command); + return Effect.succeed({ sequence: 42 }); + }, + }; + const result = yield* sendThreadOutput(sendSource, { + identifier: "thread-r", + message: " Tell me the status. ", + commandId: CommandId.make("command-send"), + messageId: MessageId.make("message-send"), + createdAt: "2026-08-22T00:00:00.000Z", + }); + + assert.deepStrictEqual(result, { + kind: "accepted", + environmentId: "env-runner", + threadId: "thread-runner", + messageId: "message-send", + }); + assert.deepStrictEqual(dispatched, [ + { + type: "thread.turn.start", + commandId: "command-send", + threadId: "thread-runner", + message: { + messageId: "message-send", + role: "user", + text: "Tell me the status.", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + createdAt: "2026-08-22T00:00:00.000Z", + }, + ]); + }), +); + +it.effect("rejects blank, missing, ambiguous, and oversized sends before dispatch", () => + Effect.gen(function* () { + const { source } = runnerSource(); + let dispatchCount = 0; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: { + ...source.shell, + threads: [ + source.shell.threads[0]!, + { ...source.shell.threads[0]!, id: ThreadId.make("thread-rival") }, + ], + }, + dispatch: () => { + dispatchCount += 1; + return Effect.succeed({ sequence: 1 }); + }, + }; + const input = { + message: "hello", + commandId: CommandId.make("command-send-invalid"), + messageId: MessageId.make("message-send-invalid"), + createdAt: "2026-08-22T00:00:00.000Z", + }; + + const blank = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: " " }), + ); + const missing = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: "missing" }), + ); + const ambiguous = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: "thread-r" }), + ); + const oversized = yield* Effect.result( + sendThreadOutput(sendSource, { + ...input, + identifier: "thread-runner", + message: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + }), + ); + + assert.strictEqual(blank._tag, "Failure"); + assert.strictEqual(blank._tag === "Failure" ? blank.failure._tag : "", "ThreadSendTargetError"); + assert.strictEqual(missing._tag, "Failure"); + assert.strictEqual(ambiguous._tag, "Failure"); + if (ambiguous._tag === "Failure" && ambiguous.failure._tag === "ThreadSendTargetError") { + assert.deepStrictEqual(ambiguous.failure.candidates, ["thread-rival", "thread-runner"]); + } + assert.strictEqual(oversized._tag, "Failure"); + assert.strictEqual( + oversized._tag === "Failure" ? oversized.failure._tag : "", + "ThreadSendMessageError", + ); + assert.strictEqual(dispatchCount, 0); + }), +); + +it.effect("does not report acceptance when authoritative dispatch rejects the send", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const result = yield* Effect.result( + sendThreadOutput( + { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => + decideOrchestrationCommand({ + // Empty attachments make the client turn-start representation + // identical to the normalized internal command for this test. + command: command as unknown as OrchestrationCommand, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }).pipe( + Effect.asVoid, + Effect.mapError( + (cause) => new ThreadCliError({ operation: "test decider rejection", cause }), + ), + Effect.provide(NodeServices.layer), + ), + }, + { + identifier: "thread-runner", + message: "hello", + commandId: CommandId.make("command-send-rejected"), + messageId: MessageId.make("message-send-rejected"), + createdAt: "2026-08-22T00:00:00.000Z", + }, + ), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure._tag, "ThreadCliError"); + if (result.failure._tag === "ThreadCliError") { + assert.strictEqual( + (result.failure.cause as { readonly _tag?: string })._tag, + "OrchestrationCommandInvariantError", + ); + } + } + }), +); + +it.effect("issues read and operate scopes and revokes send sessions on every exit path", () => + Effect.gen(function* () { + const issuedScopes: string[][] = []; + const revoked: string[] = []; + const auth = { + issueSession: ({ scopes }: { scopes: string[] }) => { + issuedScopes.push(scopes); + return Effect.succeed({ sessionId: `send-${issuedScopes.length}`, token: "token" }); + }, + revokeSession: (sessionId: string) => { + revoked.push(sessionId); + return Effect.void; + }, + } as never; + + yield* withSendSession(auth, () => Effect.succeed("ok")); + yield* Effect.result(withSendSession(auth, () => Effect.fail("rejected"))); + yield* Effect.result( + withSendSession(auth, () => Effect.fail({ _tag: "TimeoutException" as const })), + ); + + assert.deepStrictEqual(issuedScopes, [ + ["orchestration:read", "orchestration:operate"], + ["orchestration:read", "orchestration:operate"], + ["orchestration:read", "orchestration:operate"], + ]); + assert.deepStrictEqual(revoked, ["send-1", "send-2", "send-3"]); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 3124e104687f..720cd97b2bbd 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -1,7 +1,13 @@ import { + AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + CommandId, EnvironmentHttpApi, EnvironmentId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + TrimmedNonEmptyString, + type ClientOrchestrationCommand, type ExecutionEnvironmentDescriptor, type OrchestrationProjectShell, type OrchestrationShellSnapshot, @@ -11,6 +17,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -137,9 +144,60 @@ export const ThreadReadResult = Schema.Struct({ activitiesTruncated: Schema.Boolean, originalActivityCount: Schema.optional(Schema.Number), }); +export const ThreadSendAcceptedResult = Schema.Struct({ + kind: Schema.Literal("accepted"), + ...ThreadIdentity.fields, + messageId: Schema.String, +}); const decodeThreadCurrentResult = Schema.decodeUnknownEffect(ThreadCurrentResult); const decodeThreadListResult = Schema.decodeUnknownEffect(ThreadListResult); const decodeThreadReadResult = Schema.decodeUnknownEffect(ThreadReadResult); +const decodeThreadSendAcceptedResult = Schema.decodeUnknownEffect(ThreadSendAcceptedResult); +const decodeThreadSendMessage = Schema.decodeUnknownEffect( + TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), +); + +export class ThreadSendTargetError extends Schema.TaggedErrorClass()( + "ThreadSendTargetError", + { + reason: Schema.Literals(["not-found", "ambiguous"]), + identifier: Schema.String, + candidates: Schema.optional(Schema.Array(Schema.String)), + candidatesTruncated: Schema.optional(Schema.Boolean), + originalCandidateCount: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + if (this.reason === "not-found") { + return this.identifier.length === 0 + ? "A non-blank LastCode thread id or prefix is required." + : `LastCode thread '${this.identifier}' was not found.`; + } + const candidates = this.candidates ?? []; + const suffix = this.candidatesTruncated + ? ` (showing ${candidates.length} of ${this.originalCandidateCount})` + : ""; + return `LastCode thread prefix '${this.identifier}' is ambiguous: ${candidates.join(", ")}${suffix}.`; + } +} + +export class ThreadSendMessageError extends Schema.TaggedErrorClass()( + "ThreadSendMessageError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return `--message must contain text and be at most ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} characters.`; + } +} + +export class ThreadSendServerUnavailableError extends Schema.TaggedErrorClass()( + "ThreadSendServerUnavailableError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "The owning LastCode server is not available; thread send has no offline fallback."; + } +} export type ThreadTargetResolution = | { readonly kind: "resolved"; readonly thread: OrchestrationThreadShell } @@ -386,6 +444,65 @@ export interface ThreadReadSource { ) => Effect.Effect; } +export interface ThreadSendSource { + readonly descriptor: ExecutionEnvironmentDescriptor; + readonly shell: OrchestrationShellSnapshot; + readonly dispatch: ( + command: Extract, + ) => Effect.Effect; +} + +export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( + source: ThreadSendSource, + input: { + readonly identifier: string; + readonly message: string; + readonly commandId: CommandId; + readonly messageId: MessageId; + readonly createdAt: string; + }, +) { + const resolution = resolveThreadTarget(source.shell.threads, input.identifier); + if (resolution.kind !== "resolved") { + return yield* new ThreadSendTargetError({ + reason: resolution.kind, + identifier: resolution.identifier, + ...(resolution.kind === "ambiguous" + ? { + candidates: resolution.candidates, + ...(resolution.candidatesTruncated ? { candidatesTruncated: true } : {}), + ...(resolution.originalCandidateCount !== undefined + ? { originalCandidateCount: resolution.originalCandidateCount } + : {}), + } + : {}), + }); + } + const message = yield* decodeThreadSendMessage(input.message).pipe( + Effect.mapError((cause) => new ThreadSendMessageError({ cause })), + ); + yield* source.dispatch({ + type: "thread.turn.start", + commandId: input.commandId, + threadId: resolution.thread.id, + message: { + messageId: input.messageId, + role: "user", + text: message, + attachments: [], + }, + runtimeMode: resolution.thread.runtimeMode, + interactionMode: resolution.thread.interactionMode, + createdAt: input.createdAt, + }); + return yield* decodeThreadSendAcceptedResult({ + kind: "accepted", + environmentId: source.descriptor.environmentId, + threadId: resolution.thread.id, + messageId: input.messageId, + }); +}); + export const currentThreadOutput = Effect.fn("currentThreadOutput")(function* ( source: ThreadReadSource, context: { readonly threadId?: string; readonly home?: string } = { @@ -542,6 +659,19 @@ export const withReadSession = ( ({ sessionId }) => auth.revokeSession(sessionId).pipe(Effect.ignore({ log: true })), ); +export const withSendSession = ( + auth: EnvironmentAuth.EnvironmentAuth["Service"], + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + auth.issueSession({ + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + label: "lastcode thread cli", + }), + ({ token }) => run(token), + ({ sessionId }) => auth.revokeSession(sessionId).pipe(Effect.ignore({ log: true })), + ); + const tryRunLiveThreadRead = Effect.fn("tryRunLiveThreadRead")(function* ( config: ServerConfig.ServerConfig["Service"], minimumLogLevel: ServerConfig.ServerConfig["Service"]["logLevel"], @@ -663,6 +793,91 @@ const runThreadRead = Effect.fn("runThreadRead")(function* ( }); }); +const runThreadSend = Effect.fn("runThreadSend")(function* ( + flags: CliAuthLocationFlags, + identifier: string, + message: string, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadSendServerUnavailableError({ + cause: new Error("The active home has no recorded server runtime."), + }); + } + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptor = yield* client.metadata.descriptor().pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError((cause) => new ThreadSendServerUnavailableError({ cause })), + ); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const output = yield* withSendSession(auth, (token) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${token}` }; + const shell = yield* client.orchestration.shellSnapshot({ headers }).pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError( + (cause) => new ThreadCliError({ operation: "live send target lookup", cause }), + ), + ); + const crypto = yield* Crypto.Crypto; + const commandId = CommandId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send command id generation", cause }), + ), + ), + ); + const messageId = MessageId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send message id generation", cause }), + ), + ), + ); + return yield* sendThreadOutput( + { + descriptor, + shell, + dispatch: (command) => + client.orchestration + .dispatch({ + headers, + payload: command, + } as Parameters[0]) + .pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.asVoid, + Effect.mapError( + (cause) => new ThreadCliError({ operation: "live send dispatch", cause }), + ), + ), + }, + { + identifier, + message, + commandId, + messageId, + createdAt: DateTime.formatIso(yield* DateTime.now), + }, + ); + }), + ); + yield* Console.log(yield* encodeJson(output)); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Print stable JSON output."), Flag.withDefault(false), @@ -735,7 +950,23 @@ const readCommand = Command.make("read", { ), ); +const sendCommand = Command.make("send", { + ...threadLocationFlags, + json: jsonFlag, + thread: Argument.string("thread").pipe( + Argument.withDescription("Exact LastCode thread id or unambiguous id prefix."), + ), + message: Flag.string("message").pipe( + Flag.withDescription("User-directed message to send to the target thread."), + ), +}).pipe( + Command.withDescription("Send a user-directed message to one live thread."), + Command.withHandler((flags) => + runThreadSend(flags, flags.thread, flags.message).pipe(Effect.provide(FetchHttpClient.layer)), + ), +); + export const threadCommand = Command.make("thread").pipe( - Command.withDescription("Inspect LastCode threads."), - Command.withSubcommands([currentCommand, listCommand, readCommand]), + Command.withDescription("Inspect and message LastCode threads."), + Command.withSubcommands([currentCommand, listCommand, readCommand, sendCommand]), ); diff --git a/apps/server/src/provider/CodexThreadTool.test.ts b/apps/server/src/provider/CodexThreadTool.test.ts index c9fe0ce97078..d05d7c5e89dc 100644 --- a/apps/server/src/provider/CodexThreadTool.test.ts +++ b/apps/server/src/provider/CodexThreadTool.test.ts @@ -43,7 +43,7 @@ it("renders an ordinary Node-hosted wrapper pinned to its owning home", () => { stateDir: "/srv/lastcode home/userdata", electronRunAsNode: false, }), - "#!/bin/sh\ncase \"$1\" in\n current|list|read) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", + "#!/bin/sh\ncase \"$1\" in\n current|list|read|send) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", ); }); @@ -191,7 +191,7 @@ it.effect("routes pinned flags through each real thread leaf parser", () => ), electronRunAsNode: "0", }); - for (const command of ["current", "list", "read"] as const) { + for (const command of ["current", "list", "read", "send"] as const) { const output = yield* Effect.tryPromise( () => new Promise((resolve, reject) => { @@ -203,10 +203,10 @@ it.effect("routes pinned flags through each real thread leaf parser", () => ); assert.match(output, new RegExp(`t3 thread ${command}`)); } - const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["send"], { + const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["wait"], { encoding: "utf8", }); assert.strictEqual(unsupported.status, 64); - assert.match(unsupported.stderr, /unsupported command 'send'/); + assert.match(unsupported.stderr, /unsupported command 'wait'/); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), ); diff --git a/apps/server/src/provider/CodexThreadTool.ts b/apps/server/src/provider/CodexThreadTool.ts index f1dc47d049bf..461923c15eca 100644 --- a/apps/server/src/provider/CodexThreadTool.ts +++ b/apps/server/src/provider/CodexThreadTool.ts @@ -37,7 +37,7 @@ export function renderCodexThreadToolWrapper(input: CodexThreadToolInvocation): "thread", ].join(" "); const pinnedFlags = `--base-dir ${shellQuote(input.baseDir)} --state-dir ${shellQuote(input.stateDir)}`; - return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; + return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read|send) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; } export const materializeCodexThreadTool = Effect.fn("materializeCodexThreadTool")( diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md index 35216da12bc6..ac1d6ad2099f 100644 --- a/docs/user/codex-thread-tools.md +++ b/docs/user/codex-thread-tools.md @@ -1,16 +1,18 @@ -# Codex thread inspection +# Codex thread tools -On supported hosts, LastCode adds `lastcode-thread` to the `PATH` of Codex sessions it starts. The command identifies -the current LastCode thread and reads active threads owned by the same LastCode environment: +On supported hosts, LastCode adds `lastcode-thread` to the `PATH` of Codex sessions it starts. +The command identifies the current LastCode thread, reads active threads, and sends a +user-directed message to a live thread owned by the same LastCode environment: ```sh lastcode-thread current --json lastcode-thread list --json lastcode-thread read --turn-limit 5 --json +lastcode-thread send --message --json ``` Thread IDs are resolved locally. An exact ID always wins; a prefix must identify exactly one -active thread. Ambiguous JSON responses include a small sorted candidate list and report when +active thread. Ambiguous resolution includes a small sorted candidate list and reports when additional matches were omitted. Archived and deleted threads are not included. `list` returns at most the 50 most recently updated active threads, with thread ID as @@ -22,6 +24,13 @@ budget and caps activity records. Unresolved approval and user-input requests ar with remaining activity slots filled by the newest activity. Its JSON reports when text or activity counts were truncated. +`send` requires the owning LastCode server to be running and never mutates an offline database. +It uses the target thread's current runtime and interaction settings. A successful response is +`{"kind":"accepted","environmentId":"...","threadId":"...","messageId":"..."}`: this +confirms that LastCode persisted the request, not that the provider finished it. Blank or +oversized messages, missing or ambiguous targets, authorization failures, and rejected dispatches +fail without reporting acceptance. + The bundled thread command is currently available on POSIX Node hosts and packaged macOS. Windows and packaged Linux AppImage Codex sessions still receive LastCode thread and home identity, but do not receive a `lastcode-thread` launcher. Windows has no POSIX launcher; @@ -33,6 +42,7 @@ wrapper stored in its LastCode home: ```sh ssh ~/.lastcode/userdata/bin/lastcode-thread list --json ssh ~/.lastcode/userdata/bin/lastcode-thread read --json +ssh ~/.lastcode/userdata/bin/lastcode-thread send --message --json ``` `~/.lastcode` is the default home. If that host uses a custom LastCode home, use its explicit From 0972371496f041dc374d1b0ea2b6f3022e1bb8fb Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 01:58:27 -0700 Subject: [PATCH 04/23] feat(lastcode): wait for exact thread replies (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tracked inter-thread message needs an exact, resumable answer path; reading the newest transcript entry is not reliable enough. This adds opt-in `send --wait` and standalone `wait` commands. Only tracked sends create a narrow correlation record keyed by thread and message; provider startup resolves it to the exact turn, and an authenticated event-driven wait returns that turn’s bounded assistant response or a resumable handle. Plain send remains unchanged. The implementation uses one additive projection table and no polling, MCP daemon, host registry, UI, or generic workflow coordinator. The design was deliberately complexity-braked for one user across two small hosts: exhaustive transport/interruption simulations are deferred, while the core live composed workflow, exact correlation, timeout handles, authoritative failures, subscription readiness, migration/projection behavior, and credential cleanup are covered. Validation: three Luna-high review rounds ending clean across correctness, KISS, and best-practices; full required local CI; 2,664 server tests passed in the publication gate. Implemented with GPT-5.6 Sol (medium) through the Codex harness. --- apps/server/src/bin.test.ts | 284 +++++++++++++++- apps/server/src/cli/thread.test.ts | 51 +++ apps/server/src/cli/thread.ts | 310 +++++++++++++++--- .../Layers/OrchestrationEngine.ts | 4 + .../Layers/ProjectionPipeline.test.ts | 81 +++++ .../Layers/ProjectionPipeline.ts | 31 ++ .../Layers/ProviderCommandReactor.test.ts | 91 ++++- .../Layers/ProviderCommandReactor.ts | 71 +++- .../Layers/TurnRequestWaitQuery.ts | 82 +++++ .../Services/OrchestrationEngine.ts | 32 +- apps/server/src/orchestration/decider.ts | 18 + .../decider.turnRequestCorrelation.test.ts | 44 +++ apps/server/src/orchestration/http.ts | 94 ++++++ .../ProjectionTurnRequestCorrelations.ts | 61 ++++ apps/server/src/persistence/Migrations.ts | 2 + ..._ProjectionTurnRequestCorrelations.test.ts | 48 +++ .../045_ProjectionTurnRequestCorrelations.ts | 17 + .../ProjectionTurnRequestCorrelations.ts | 43 +++ .../src/provider/CodexThreadTool.test.ts | 8 +- apps/server/src/provider/CodexThreadTool.ts | 2 +- .../src/relay/AgentAwarenessRelay.test.ts | 4 + .../serverRuntimeStartup.reconcile.test.ts | 4 + apps/server/src/serverRuntimeStartup.test.ts | 6 + docs/user/codex-thread-tools.md | 12 + .../contracts/src/environmentHttp.test.ts | 20 ++ packages/contracts/src/environmentHttp.ts | 65 +++- packages/contracts/src/orchestration.test.ts | 28 ++ packages/contracts/src/orchestration.ts | 35 ++ 28 files changed, 1477 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts create mode 100644 apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts create mode 100644 apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts create mode 100644 apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts create mode 100644 apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 558a595e7b17..e1521ad5f98c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -12,17 +12,21 @@ import { EnvironmentId, EnvironmentMetadataHttpApi, EnvironmentOrchestrationHttpApi, + MessageId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; @@ -55,6 +59,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import { ServerEnvironment } from "./environment/ServerEnvironment.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); const isThreadSendMessageError = Schema.is(ThreadSendMessageError); @@ -78,7 +83,8 @@ const captureStdout = (effect: Effect.Effect) => const output = (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? ""; - return { result, output }; + const errorOutput = (yield* TestConsole.errorLines).join("\n"); + return { result, output, errorOutput }; }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); const makeCliTestServerConfig = (baseDir: string) => @@ -149,7 +155,22 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ), ); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( - Layer.provide(Layer.mergeAll(orchestrationHttpApiLayer, metadataLayer)), + Layer.provide( + Layer.mergeAll(orchestrationHttpApiLayer, metadataLayer).pipe( + Layer.provide( + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("env-thread-live")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("env-thread-live"), + label: "CLI integration", + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "test", + capabilities: { repositoryIdentity: true }, + }), + }), + ), + ), + ), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { @@ -857,6 +878,265 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(sent?.role, "user"); assert.equal(sent?.text, "Report the current status."); } + const trackedEvents = yield* engine.subscribeDomainEvents; + const composedTurnId = TurnId.make("turn-composed-wait"); + const responder = yield* trackedEvents.pipe( + Stream.filter( + (event) => + event.type === "thread.turn-start-requested" && + event.payload.trackRequestCorrelation === true, + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("tracked request stream ended"), + onSome: (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.die("unexpected tracked request event"); + } + const responseAt = event.payload.createdAt; + return Effect.gen(function* () { + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`turn-request:${event.eventId}`), + threadId, + messageId: event.payload.messageId, + outcome: { kind: "started", turnId: composedTurnId }, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-composed-wait-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: composedTurnId, + lastError: null, + updatedAt: responseAt, + }, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-composed-wait-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: responseAt, + }, + createdAt: responseAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ + threadId, + messageId: event.payload.messageId, + }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-composed-wait-answer"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + delta: "Composed exact answer", + turnId: composedTurnId, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-composed-wait-answer-complete"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + turnId: composedTurnId, + createdAt: responseAt, + }); + return event.payload.messageId; + }); + }, + }), + ), + Effect.forkChild, + ); + const composed = yield* captureStdout( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "Compose and wait.", + "--wait", + "--base-dir", + baseDir, + "--json", + ]), + ); + const composedMessageId = yield* Fiber.join(responder); + const recoveryLine = composed.errorOutput + .split("\n") + .find((line) => line.startsWith("LASTCODE_WAIT_HANDLE=")); + assert.isDefined(recoveryLine); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact recovery framing under test. + assert.deepStrictEqual(JSON.parse(recoveryLine!.slice("LASTCODE_WAIT_HANDLE=".length)), { + kind: "wait-handle", + environmentId: "env-thread-live", + threadId, + messageId: composedMessageId, + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + const composedResult = JSON.parse(composed.output) as { + readonly kind: string; + readonly environmentId: string; + readonly threadId: string; + readonly messageId: string; + readonly turnId: string; + readonly response: string; + readonly responseTruncated: boolean; + }; + assert.deepStrictEqual(composedResult, { + kind: "completed", + environmentId: "env-thread-live", + threadId, + messageId: composedMessageId, + turnId: composedTurnId, + response: "Composed exact answer", + responseTruncated: false, + }); + const trackedTurnId = TurnId.make("turn-live-wait"); + const trackedMessageId = MessageId.make("message-live-wait-request"); + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-live-wait-request"), + threadId, + message: { + messageId: trackedMessageId, + role: "user", + text: "Wait for this exact turn.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:live-wait"), + threadId, + messageId: trackedMessageId, + outcome: { kind: "started", turnId: trackedTurnId }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-live-wait-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: trackedTurnId, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-live-wait-answer"), + threadId, + messageId: MessageId.make("message-live-wait-answer"), + delta: "Exact tracked answer", + turnId: trackedTurnId, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-live-wait-answer-complete"), + threadId, + messageId: MessageId.make("message-live-wait-answer"), + turnId: trackedTurnId, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-live-wait-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + const recoveryHandle = { + kind: "wait-handle" as const, + environmentId: "env-thread-live", + threadId, + messageId: trackedMessageId, + }; + const resumed = yield* captureStdout( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify(recoveryHandle), + "--base-dir", + baseDir, + "--json", + ]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + const completed = JSON.parse(resumed.output) as { + readonly kind: string; + readonly turnId: string; + readonly response: string; + }; + assert.strictEqual(completed.kind, "completed"); + assert.strictEqual(completed.turnId, trackedTurnId); + assert.strictEqual(completed.response, "Exact tracked answer"); + const missingCorrelation = yield* Effect.result( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify({ + ...recoveryHandle, + messageId: "message-not-projected", + }), + "--base-dir", + baseDir, + ]), + ); + const wrongEnvironment = yield* Effect.result( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify({ + ...recoveryHandle, + environmentId: "another-environment", + }), + "--base-dir", + baseDir, + ]), + ); + assert.strictEqual(missingCorrelation._tag, "Failure"); + assert.strictEqual(wrongEnvironment._tag, "Failure"); + assert.equal((yield* auth.listSessions()).length, beforeSessions.length); yield* engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-send-live-rival"), diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index f5720baec934..520a49e14532 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -27,7 +27,9 @@ import { boundTranscriptMessages, currentThreadOutput, listThreadsOutput, + isAuthoritativeDispatchFailure, readThreadOutput, + retryAmbiguousTrackedDispatch, resolveThreadTarget, sendThreadOutput, threadLifecycle, @@ -36,6 +38,31 @@ import { withSendSession, } from "./thread.ts"; +it.effect("does not retry an authoritative tracked dispatch rejection", () => + Effect.gen(function* () { + assert.isTrue( + isAuthoritativeDispatchFailure({ + _tag: "EnvironmentInternalError", + reason: "orchestration_dispatch_failed", + }), + ); + let attempts = 0; + const result = yield* Effect.result( + retryAmbiguousTrackedDispatch( + Effect.sync(() => { + attempts += 1; + }).pipe( + Effect.andThen( + Effect.fail(new ThreadCliError({ operation: "live send dispatch", cause: "rejected" })), + ), + ), + ), + ); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(attempts, 1); + }), +); + const shellThread = (id: string) => ({ id: ThreadId.make(id) }) as OrchestrationThreadShell; const activity = (id: string, summary: string, createdAt: string) => @@ -594,6 +621,30 @@ it.effect("prepares and dispatches an exact accepted send using the target threa }), ); +it.effect("marks only explicitly tracked sends for wait correlation", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => Effect.sync(() => dispatched.push(command)), + }; + const input = { + identifier: "thread-runner", + message: "status", + commandId: CommandId.make("command-tracked"), + messageId: MessageId.make("message-tracked"), + createdAt: "2026-08-22T00:00:00.000Z", + }; + yield* sendThreadOutput(sendSource, input); + yield* sendThreadOutput(sendSource, { ...input, trackRequestCorrelation: true }); + + assert.notProperty(dispatched[0] as object, "trackRequestCorrelation"); + assert.deepInclude(dispatched[1] as object, { trackRequestCorrelation: true }); + }), +); + it.effect("rejects blank, missing, ambiguous, and oversized sends before dispatch", () => Effect.gen(function* () { const { source } = runnerSource(); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 720cd97b2bbd..9a512e4886e9 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -15,6 +15,7 @@ import { type OrchestrationThreadDetailSnapshot, type OrchestrationThreadShell, ThreadId, + ThreadWaitHandle, } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; @@ -41,7 +42,11 @@ import { layerReadOnlyConfig as SqlitePersistenceLayerReadOnly } from "../persis import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { type CliAuthLocationFlags, resolveThreadInspectionConfig } from "./config.ts"; +import { + DurationFromString, + type CliAuthLocationFlags, + resolveThreadInspectionConfig, +} from "./config.ts"; export const THREAD_READ_DEFAULT_TURN_LIMIT = 5; export const THREAD_READ_MAX_TURN_LIMIT = 20; @@ -49,6 +54,7 @@ export const THREAD_LIST_MAX_RESULTS = 50; export const THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS = 20; export const THREAD_TRANSCRIPT_MAX_CHARS = 64_000; export const THREAD_ACTIVITY_MAX_RESULTS = 200; +export const THREAD_WAIT_MAX_TIMEOUT_MS = 600_000; export class ThreadCliError extends Schema.TaggedErrorClass()("ThreadCliError", { operation: Schema.String, @@ -156,6 +162,37 @@ const decodeThreadSendAcceptedResult = Schema.decodeUnknownEffect(ThreadSendAcce const decodeThreadSendMessage = Schema.decodeUnknownEffect( TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ); +const decodeThreadWaitTimeoutMs = Schema.decodeUnknownEffect( + Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: THREAD_WAIT_MAX_TIMEOUT_MS }), + ), +); +const decodeThreadWaitDuration = Schema.decodeUnknownEffect(DurationFromString); +const decodeThreadWaitHandleString = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadWaitHandle), +); + +const isAuthoritativeWaitFailure = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) return false; + return [ + "EnvironmentRequestInvalidError", + "EnvironmentScopeRequiredError", + "EnvironmentResourceNotFoundError", + "EnvironmentInternalError", + "EnvironmentAuthInvalidError", + ].includes(String(cause._tag)); +}; +export const isAuthoritativeDispatchFailure = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) return false; + return [ + "EnvironmentRequestInvalidError", + "EnvironmentScopeRequiredError", + "EnvironmentResourceNotFoundError", + "EnvironmentAuthInvalidError", + "EnvironmentInternalError", + ].includes(String(cause._tag)); +}; export class ThreadSendTargetError extends Schema.TaggedErrorClass()( "ThreadSendTargetError", @@ -199,6 +236,20 @@ export class ThreadSendServerUnavailableError extends Schema.TaggedErrorClass()( + "ThreadDispatchUnknownError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "LastCode could not confirm whether the tracked message dispatch was accepted."; + } +} +const isThreadDispatchUnknownError = Schema.is(ThreadDispatchUnknownError); + +export const retryAmbiguousTrackedDispatch = ( + dispatch: Effect.Effect, +) => dispatch.pipe(Effect.catchTag("ThreadDispatchUnknownError", () => dispatch)); + export type ThreadTargetResolution = | { readonly kind: "resolved"; readonly thread: OrchestrationThreadShell } | { @@ -449,7 +500,7 @@ export interface ThreadSendSource { readonly shell: OrchestrationShellSnapshot; readonly dispatch: ( command: Extract, - ) => Effect.Effect; + ) => Effect.Effect; } export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( @@ -460,6 +511,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( readonly commandId: CommandId; readonly messageId: MessageId; readonly createdAt: string; + readonly trackRequestCorrelation?: true; }, ) { const resolution = resolveThreadTarget(source.shell.threads, input.identifier); @@ -493,6 +545,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( }, runtimeMode: resolution.thread.runtimeMode, interactionMode: resolution.thread.interactionMode, + ...(input.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), createdAt: input.createdAt, }); return yield* decodeThreadSendAcceptedResult({ @@ -797,6 +850,8 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( flags: CliAuthLocationFlags, identifier: string, message: string, + waitForCompletion: boolean, + timeoutMs: number, ) { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveThreadInspectionConfig(flags, logLevel); @@ -815,59 +870,114 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( return yield* Effect.gen(function* () { const auth = yield* EnvironmentAuth.EnvironmentAuth; - const output = yield* withSendSession(auth, (token) => - Effect.gen(function* () { - const headers = { authorization: `Bearer ${token}` }; - const shell = yield* client.orchestration.shellSnapshot({ headers }).pipe( - Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), - Effect.mapError( - (cause) => new ThreadCliError({ operation: "live send target lookup", cause }), - ), - ); - const crypto = yield* Crypto.Crypto; - const commandId = CommandId.make( - yield* crypto.randomUUIDv4.pipe( + let waitHandle: ThreadWaitHandle | undefined; + const dispatched = yield* Effect.result( + withSendSession(auth, (token) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${token}` }; + const shell = yield* client.orchestration.shellSnapshot({ headers }).pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), Effect.mapError( - (cause) => new ThreadCliError({ operation: "send command id generation", cause }), + (cause) => new ThreadCliError({ operation: "live send target lookup", cause }), ), - ), - ); - const messageId = MessageId.make( - yield* crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => new ThreadCliError({ operation: "send message id generation", cause }), + ); + const crypto = yield* Crypto.Crypto; + const commandId = CommandId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send command id generation", cause }), + ), ), - ), - ); - return yield* sendThreadOutput( - { - descriptor, - shell, - dispatch: (command) => - client.orchestration - .dispatch({ - headers, - payload: command, - } as Parameters[0]) - .pipe( - Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), - Effect.asVoid, - Effect.mapError( - (cause) => new ThreadCliError({ operation: "live send dispatch", cause }), - ), - ), - }, - { - identifier, - message, - commandId, - messageId, - createdAt: DateTime.formatIso(yield* DateTime.now), - }, + ); + const messageId = MessageId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send message id generation", cause }), + ), + ), + ); + const resolution = resolveThreadTarget(shell.threads, identifier); + if (resolution.kind === "resolved") { + waitHandle = { + kind: "wait-handle", + environmentId: descriptor.environmentId, + threadId: resolution.thread.id, + messageId, + }; + } + return yield* sendThreadOutput( + { + descriptor, + shell, + dispatch: (command) => { + const dispatch = client.orchestration + .dispatch({ + headers, + payload: command, + } as Parameters[0]) + .pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.asVoid, + Effect.mapError((cause) => + waitForCompletion && !isAuthoritativeDispatchFailure(cause) + ? new ThreadDispatchUnknownError({ cause }) + : new ThreadCliError({ operation: "live send dispatch", cause }), + ), + ); + return waitForCompletion ? retryAmbiguousTrackedDispatch(dispatch) : dispatch; + }, + }, + { + identifier, + message, + commandId, + messageId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}), + }, + ); + }), + ), + ); + if (dispatched._tag === "Failure") { + const recoveryHandle = waitHandle; + if ( + waitForCompletion && + recoveryHandle !== undefined && + isThreadDispatchUnknownError(dispatched.failure) + ) { + yield* Console.error(`LASTCODE_WAIT_HANDLE=${yield* encodeJson(recoveryHandle)}`); + return yield* Console.log( + yield* encodeJson({ kind: "dispatch-unknown", waitHandle: recoveryHandle }), ); - }), + } + return yield* dispatched.failure; + } + const acceptedWaitHandle = waitHandle; + if (!waitForCompletion || acceptedWaitHandle === undefined) { + return yield* Console.log(yield* encodeJson(dispatched.success)); + } + yield* Console.error(`LASTCODE_WAIT_HANDLE=${yield* encodeJson(acceptedWaitHandle)}`); + const waitResult = yield* withReadSession(auth, (token) => + Effect.result( + client.orchestration + .waitThread({ + headers: { authorization: `Bearer ${token}` }, + payload: { waitHandle: acceptedWaitHandle, timeoutMs }, + }) + .pipe(Effect.timeout(`${timeoutMs + 5_000} millis`)), + ), + ); + if (waitResult._tag === "Failure" && isAuthoritativeWaitFailure(waitResult.failure)) { + return yield* new ThreadCliError({ operation: "live wait", cause: waitResult.failure }); + } + return yield* Console.log( + yield* encodeJson( + waitResult._tag === "Success" + ? waitResult.success + : { kind: "transport-unknown", waitHandle: acceptedWaitHandle }, + ), ); - yield* Console.log(yield* encodeJson(output)); }).pipe( Effect.provide( EnvironmentAuth.runtimeLayer.pipe( @@ -878,6 +988,65 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( ); }); +const runThreadWait = Effect.fn("runThreadWait")(function* ( + flags: CliAuthLocationFlags, + rawHandle: string, + timeoutMs: number, +) { + const waitHandle = yield* decodeThreadWaitHandleString(rawHandle).pipe( + Effect.mapError((cause) => new ThreadCliError({ operation: "wait handle decoding", cause })), + ); + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadSendServerUnavailableError({ + cause: new Error("The active home has no recorded server runtime."), + }); + } + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptor = yield* client.metadata.descriptor().pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError((cause) => new ThreadSendServerUnavailableError({ cause })), + ); + if (descriptor.environmentId !== waitHandle.environmentId) { + return yield* new ThreadCliError({ + operation: "wait environment validation", + cause: new Error( + `Wait handle belongs to '${waitHandle.environmentId}', not '${descriptor.environmentId}'.`, + ), + }); + } + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const result = yield* withReadSession(auth, (token) => + Effect.result( + client.orchestration + .waitThread({ + headers: { authorization: `Bearer ${token}` }, + payload: { waitHandle, timeoutMs }, + }) + .pipe(Effect.timeout(`${timeoutMs + 5_000} millis`)), + ), + ); + if (result._tag === "Failure" && isAuthoritativeWaitFailure(result.failure)) { + return yield* new ThreadCliError({ operation: "live wait", cause: result.failure }); + } + yield* Console.log( + yield* encodeJson( + result._tag === "Success" ? result.success : { kind: "transport-unknown", waitHandle }, + ), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), + ), + ), + ); +}); + const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Print stable JSON output."), Flag.withDefault(false), @@ -959,14 +1128,51 @@ const sendCommand = Command.make("send", { message: Flag.string("message").pipe( Flag.withDescription("User-directed message to send to the target thread."), ), + wait: Flag.boolean("wait").pipe( + Flag.withDescription("Wait for the exact tracked turn to finish."), + Flag.withDefault(false), + ), + timeout: Flag.string("timeout").pipe( + Flag.withDescription("Maximum wait duration (for example, '10 minutes')."), + Flag.withDefault("10 minutes"), + ), }).pipe( Command.withDescription("Send a user-directed message to one live thread."), Command.withHandler((flags) => - runThreadSend(flags, flags.thread, flags.message).pipe(Effect.provide(FetchHttpClient.layer)), + decodeThreadWaitDuration(flags.timeout).pipe( + Effect.map(Duration.toMillis), + Effect.flatMap(decodeThreadWaitTimeoutMs), + Effect.flatMap((timeoutMs) => + runThreadSend(flags, flags.thread, flags.message, flags.wait, timeoutMs), + ), + Effect.provide(FetchHttpClient.layer), + ), + ), +); + +const waitCommand = Command.make("wait", { + ...threadLocationFlags, + json: jsonFlag, + waitHandle: Argument.string("wait-handle").pipe( + Argument.withDescription("Compact JSON wait handle returned by send --wait."), + ), + timeout: Flag.string("timeout").pipe( + Flag.withDescription("Maximum wait duration (for example, '10 minutes')."), + Flag.withDefault("10 minutes"), + ), +}).pipe( + Command.withDescription("Resume waiting for one exact tracked thread request."), + Command.withHandler((flags) => + decodeThreadWaitDuration(flags.timeout).pipe( + Effect.map(Duration.toMillis), + Effect.flatMap(decodeThreadWaitTimeoutMs), + Effect.flatMap((timeoutMs) => runThreadWait(flags, flags.waitHandle, timeoutMs)), + Effect.provide(FetchHttpClient.layer), + ), ), ); export const threadCommand = Command.make("thread").pipe( Command.withDescription("Inspect and message LastCode threads."), - Command.withSubcommands([currentCommand, listCommand, readCommand, sendCommand]), + Command.withSubcommands([currentCommand, listCommand, readCommand, sendCommand, waitCommand]), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff15..27d5c8f7bea3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -30,6 +30,7 @@ import { orchestrationCommandDuration, } from "../../observability/Metrics.ts"; import { toPersistenceSqlError } from "../../persistence/Errors.ts"; +import { makeTurnRequestWaitQuery } from "./TurnRequestWaitQuery.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { @@ -87,6 +88,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const crypto = yield* Crypto.Crypto; + const turnRequestWaitQuery = makeTurnRequestWaitQuery(sql); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -353,6 +355,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); return { + getTurnRequestWaitState: turnRequestWaitQuery.getState, + subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)), readEvents, dispatch, // Each access creates a fresh PubSub subscription so that multiple diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 912d34cecf8f..45525edbb489 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2589,6 +2589,87 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test- }, ); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-turn-correlation-test-"))( + "OrchestrationProjectionPipeline tracked correlations", + (it) => { + it.effect("tracks marked starts only and preserves the first projected resolution", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const now = "2026-08-22T00:00:00.000Z"; + for (const [index, tracked] of [false, true].entries()) { + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-correlation-start-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-correlation-start-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-correlation-start-${index}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-correlation-${index}`), + runtimeMode: "approval-required", + interactionMode: "default", + ...(tracked ? { trackRequestCorrelation: true as const } : {}), + createdAt: now, + }, + }); + } + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-correlation-resolved-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-correlation-resolved-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-correlation-resolved-1"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-correlation-1"), + outcome: { kind: "started", turnId: TurnId.make("turn-correlation") }, + }, + }); + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-correlation-resolved-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-correlation-resolved-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-correlation-resolved-2"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-correlation-1"), + outcome: { kind: "terminal", state: "error", completedAt: now }, + }, + }); + + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly messageId: string; + readonly state: string; + readonly turnId: string | null; + }>` + SELECT message_id AS "messageId", state, turn_id AS "turnId" + FROM projection_turn_request_correlations + `; + assert.deepEqual(rows, [ + { messageId: "message-correlation-1", state: "started", turnId: "turn-correlation" }, + ]); + }), + ); + }, +); + it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { const { dbPath } = yield* ServerConfig; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7d6f1f64bf84..6fb92a72fef3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -34,6 +34,7 @@ import { type ProjectionTurn, ProjectionTurnRepository, } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../../persistence/Services/ProjectionTurnRequestCorrelations.ts"; import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; @@ -43,6 +44,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRequestCorrelationRepositoryLive } from "../../persistence/Layers/ProjectionTurnRequestCorrelations.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -480,6 +482,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionTurnRequestCorrelationRepository = + yield* ProjectionTurnRequestCorrelationRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; const fileSystem = yield* FileSystem.FileSystem; @@ -1176,6 +1180,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, _attachmentSideEffects) { switch (event.type) { case "thread.turn-start-requested": { + if (event.payload.trackRequestCorrelation === true) { + yield* projectionTurnRequestCorrelationRepository.insertPending({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + requestedAt: event.payload.createdAt, + }); + } yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, messageId: event.payload.messageId, @@ -1186,6 +1197,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.turn-request-resolved": { + const outcome = event.payload.outcome; + yield* projectionTurnRequestCorrelationRepository.resolve({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + turnId: outcome.kind === "started" ? outcome.turnId : null, + state: outcome.kind === "started" ? "started" : outcome.state, + resolvedAt: outcome.kind === "started" ? event.occurredAt : outcome.completedAt, + }); + return; + } + + case "thread.deleted": { + yield* projectionTurnRequestCorrelationRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + } + case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { @@ -1770,6 +1800,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionTurnRequestCorrelationRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 43fff7b4f616..5546d41b7528 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,6 +22,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -154,6 +155,7 @@ describe("ProviderCommandReactor", () => { readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; + readonly turnRequestResolutionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly createSecondThread?: boolean; readonly startSessionEffect?: ( @@ -370,6 +372,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(SqlitePersistenceMemory), ); let titleRegenerationCompletionDispatchAttempts = 0; + let turnRequestResolutionDispatchAttempts = 0; const reactorOrchestrationLayer = Layer.effect( OrchestrationEngineService, Effect.gen(function* () { @@ -377,6 +380,15 @@ describe("ProviderCommandReactor", () => { return { readEvents: engine.readEvents, dispatch: (command) => { + if (command.type === "thread.turn-request.resolve") { + turnRequestResolutionDispatchAttempts += 1; + if ( + turnRequestResolutionDispatchAttempts <= + (input?.turnRequestResolutionDispatchFailures ?? 0) + ) { + return Effect.die(new Error("Injected turn request resolution failure")); + } + } if (command.type === "thread.title.regeneration.complete") { titleRegenerationCompletionDispatchAttempts += 1; if ( @@ -391,6 +403,8 @@ describe("ProviderCommandReactor", () => { get streamDomainEvents() { return engine.streamDomainEvents; }, + getTurnRequestWaitState: engine.getTurnRequestWaitState, + subscribeDomainEvents: engine.subscribeDomainEvents, latestSequence: engine.latestSequence, } satisfies OrchestrationEngineService["Service"]; }), @@ -433,7 +447,7 @@ describe("ProviderCommandReactor", () => { const projectionTurns = await runtime.runPromise(Effect.service(ProjectionTurnRepository)); const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); - await Effect.runPromise( + await runtime.runPromise( engine.dispatch({ type: "project.create", commandId: CommandId.make("cmd-project-create"), @@ -520,6 +534,9 @@ describe("ProviderCommandReactor", () => { get titleRegenerationCompletionDispatchAttempts() { return titleRegenerationCompletionDispatchAttempts; }, + get turnRequestResolutionDispatchAttempts() { + return turnRequestResolutionDispatchAttempts; + }, }; } @@ -624,6 +641,78 @@ describe("ProviderCommandReactor", () => { }), ); + it("finalizes a marked request with the exact provider turn id", async () => { + const harness = await createHarness(); + const observed = await harness.runEffect( + Effect.gen(function* () { + const fiber = yield* Stream.runHead( + harness.engine.streamDomainEvents.pipe( + Stream.filter((event) => event.type === "thread.turn-request-resolved"), + ), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-tracked"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-tracked"), + role: "user", + text: "track this exact turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt: "2026-01-01T00:00:00.000Z", + }); + return yield* Fiber.join(fiber); + }), + ); + + expect(observed._tag).toBe("Some"); + if (observed._tag === "Some" && observed.value.type === "thread.turn-request-resolved") { + expect(observed.value.payload.outcome).toEqual({ kind: "started", turnId: "turn-1" }); + } + const getState = harness.engine.getTurnRequestWaitState; + expect(getState).toBeDefined(); + if (getState) { + expect( + await harness.runEffect( + getState({ + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("user-message-tracked"), + }), + ), + ).toEqual({ kind: "pending" }); + } + }); + + it("does not mark a started provider session failed when correlation persistence fails", async () => { + const harness = await createHarness({ turnRequestResolutionDispatchFailures: 1 }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-correlation-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-correlation-failure"), + role: "user", + text: "start despite correlation storage failure", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.turnRequestResolutionDispatchAttempts === 1); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect(thread?.session?.status).not.toBe("error"); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 265393310569..5126883c30f5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1074,25 +1074,58 @@ const make = Effect.gen(function* () { return; } + const finalizeTrackedRequest = ( + outcome: + | { readonly kind: "started"; readonly turnId: TurnId } + | { + readonly kind: "terminal"; + readonly state: "error" | "interrupted"; + readonly completedAt: string; + }, + ) => + event.payload.trackRequestCorrelation === true + ? orchestrationEngine + .dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`turn-request:${event.eventId}`), + threadId: event.payload.threadId, + messageId: event.payload.messageId, + outcome, + createdAt: event.payload.createdAt, + }) + .pipe(Effect.asVoid) + : Effect.void; + const thread = yield* resolveThread(event.payload.threadId); if (!thread) { yield* projectionTurns.deletePendingTurnStartByThreadId({ threadId: event.payload.threadId, }); - return; + return yield* finalizeTrackedRequest({ + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + }); } const message = thread.messages.find((entry) => entry.id === event.payload.messageId); if (!message || (message.role !== "user" && message.role !== "system")) { - yield* appendProviderFailureActivity({ + const outcome = { + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + } as const; + return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.start.failed", summary: "Provider turn start failed", detail: `Turn message '${event.payload.messageId}' was not found for turn start request.`, turnId: null, createdAt: event.payload.createdAt, - }); - return; + }).pipe( + Effect.asVoid, + Effect.ensuring(finalizeTrackedRequest(outcome).pipe(Effect.ignore({ log: true }))), + ); } const isFirstUserMessageTurn = @@ -1129,9 +1162,18 @@ const make = Effect.gen(function* () { const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { - return Effect.void; + return finalizeTrackedRequest({ + kind: "terminal", + state: "interrupted", + completedAt: event.payload.createdAt, + }); } const detail = formatFailureDetail(cause); + const outcome = { + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + } as const; return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, detail, @@ -1148,6 +1190,7 @@ const make = Effect.gen(function* () { }), ), Effect.asVoid, + Effect.ensuring(finalizeTrackedRequest(outcome).pipe(Effect.ignore({ log: true }))), ); }; @@ -1181,9 +1224,21 @@ const make = Effect.gen(function* () { return; } - yield* providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + yield* providerService.sendTurn(sendTurnRequest.value).pipe( + Effect.tap((result) => + finalizeTrackedRequest({ kind: "started", turnId: result.turnId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider turn started but correlation finalization failed", { + threadId: event.payload.threadId, + messageId: event.payload.messageId, + cause: Cause.pretty(cause), + }), + ), + ), + ), + Effect.catchCause(recoverTurnStartFailure), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts new file mode 100644 index 000000000000..441c6195fbde --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -0,0 +1,82 @@ +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; +import type { TurnRequestWaitState } from "../Services/OrchestrationEngine.ts"; + +const WaitRow = Schema.Struct({ + correlationState: Schema.Literals(["pending", "started", "error", "interrupted"]), + turnId: Schema.NullOr(TurnId), + turnState: Schema.NullOr(Schema.Literals(["running", "completed", "error", "interrupted"])), + assistantMessageId: Schema.NullOr(MessageId), + response: Schema.NullOr(Schema.String), +}); + +export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, messageId: MessageId }), + Result: WaitRow, + execute: ({ threadId, messageId }) => sql` + SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", + turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", + messages.text AS "response" + FROM projection_turn_request_correlations AS correlations + LEFT JOIN projection_turns AS turns + ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id + LEFT JOIN projection_thread_messages AS messages + ON messages.message_id = turns.assistant_message_id + WHERE correlations.thread_id = ${threadId} AND correlations.message_id = ${messageId} + LIMIT 1 + `, + }); + + const getState = (input: { readonly threadId: ThreadId; readonly messageId: MessageId }) => + Effect.gen(function* () { + const threads = yield* sql<{ readonly found: number }>` + SELECT 1 AS found FROM projection_threads + WHERE thread_id = ${input.threadId} AND deleted_at IS NULL LIMIT 1 + `; + if (threads.length === 0) return { kind: "thread-not-found" } as const; + const row = yield* getRow(input); + if (Option.isNone(row)) return { kind: "correlation-not-found" } as const; + const value = row.value; + if (value.correlationState === "error" || value.correlationState === "interrupted") { + return { kind: "terminal", state: value.correlationState } as const; + } + if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { + if (value.turnState === "completed") { + if (value.assistantMessageId === null || value.response === null) { + return { kind: "pending" } as const; + } + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: value.response, + } as const; + } + return { + kind: "terminal", + state: value.turnState, + turnId: value.turnId, + } as const; + } + return { kind: "pending" } as const; + }).pipe( + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError("TurnRequestWaitQuery.getState:decode")(cause) + : toPersistenceSqlError("TurnRequestWaitQuery.getState:query")(cause), + ), + ) satisfies Effect.Effect; + + return { getState }; +}; diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index a32a45684014..25d633579c5a 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -11,21 +11,51 @@ * @module OrchestrationEngineService */ import type { + MessageId, OrchestrationClientOrigin, OrchestrationCommand, OrchestrationEvent, + ThreadId, + TurnId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; import type { OrchestrationDispatchError } from "../Errors.ts"; -import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; +import type { + OrchestrationEventStoreError, + ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; + +export type TurnRequestWaitState = + | { readonly kind: "thread-not-found" | "correlation-not-found" | "pending" } + | { + readonly kind: "terminal"; + readonly state: "completed"; + readonly turnId: TurnId; + readonly response: string; + } + | { + readonly kind: "terminal"; + readonly state: "error" | "interrupted"; + readonly turnId?: TurnId; + }; /** * OrchestrationEngineShape - Service API for orchestration command and event flow. */ export interface OrchestrationEngineShape { + readonly getTurnRequestWaitState: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; + readonly subscribeDomainEvents: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; /** * Replay persisted orchestration events from an exclusive sequence cursor. * diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 8090d7471c07..b3e20554aaad 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1123,6 +1123,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), createdAt: command.createdAt, }, }; @@ -1305,6 +1306,23 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn-request.resolve": { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-request-resolved", + payload: { + threadId: command.threadId, + messageId: command.messageId, + outcome: command.outcome, + }, + }; + } + case "thread.session.set": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts new file mode 100644 index 000000000000..06d8a4d02467 --- /dev/null +++ b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts @@ -0,0 +1,44 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + type OrchestrationEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel } from "./projector.ts"; + +it.effect("resolves tracked requests without requiring the thread to still exist", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:event-1"), + threadId: ThreadId.make("deleted-thread"), + messageId: MessageId.make("message-1"), + outcome: { kind: "started", turnId: TurnId.make("turn-1") }, + createdAt: "2026-08-22T00:00:00.000Z", + }, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }); + + const isResolved = "type" in event && event.type === "thread.turn-request-resolved"; + assert.strictEqual(isResolved, true); + if (isResolved) { + const resolved = event as Extract< + OrchestrationEvent, + { readonly type: "thread.turn-request-resolved" } + >; + assert.strictEqual(resolved.payload.threadId, "deleted-thread"); + assert.strictEqual(resolved.payload.messageId, "message-1"); + assert.deepStrictEqual(resolved.payload.outcome, { + kind: "started", + turnId: TurnId.make("turn-1"), + }); + } + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 04d54ea8effb..192b920ad7e6 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; @@ -18,6 +19,11 @@ import { } from "../auth/http.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import type { TurnRequestWaitState } from "./Services/OrchestrationEngine.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; + +const THREAD_WAIT_RESPONSE_MAX_CHARS = 64_000; export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, @@ -25,6 +31,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; + const serverEnvironment = yield* ServerEnvironment; return handlers .handle( @@ -104,6 +111,93 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( ), ); }), + ) + .handle( + "waitThread", + Effect.fn("environment.orchestration.waitThread")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const environmentId = yield* serverEnvironment.getEnvironmentId; + const handle = args.payload.waitHandle; + if (handle.environmentId !== environmentId) { + return yield* failEnvironmentInvalidRequest("wrong_environment"); + } + + const relevantEvents = (yield* orchestrationEngine.subscribeDomainEvents).pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === handle.threadId && + (event.type === "thread.turn-request-resolved" || + event.type === "thread.turn-interrupt-requested" || + event.type === "thread.session-set" || + event.type === "thread.message-sent" || + event.type === "thread.deleted"), + ), + ); + const subscription = { + latest: yield* orchestrationEngine + .getTurnRequestWaitState(handle) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ), + changes: relevantEvents, + }; + + const readUntilTerminal = ( + state: TurnRequestWaitState, + ): Effect.Effect => + state.kind === "pending" + ? subscription.changes.pipe( + Stream.runHead, + Effect.flatMap(() => + orchestrationEngine + .getTurnRequestWaitState(handle) + .pipe(Effect.flatMap(readUntilTerminal)), + ), + ) + : Effect.succeed(state); + const waited = yield* readUntilTerminal(subscription.latest).pipe( + Effect.timeoutOption(`${args.payload.timeoutMs} millis`), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(waited)) { + return { kind: "timed-out" as const, waitHandle: handle }; + } + const state = waited.value; + if (state.kind === "thread-not-found") { + return yield* failEnvironmentNotFound("thread_not_found"); + } + if (state.kind === "correlation-not-found") { + return yield* failEnvironmentNotFound("correlation_not_found"); + } + if (state.kind === "terminal" && state.state === "completed") { + const response = state.response.slice(-THREAD_WAIT_RESPONSE_MAX_CHARS); + return { + kind: "completed" as const, + environmentId, + threadId: handle.threadId, + messageId: handle.messageId, + turnId: state.turnId, + response, + responseTruncated: response.length !== state.response.length, + }; + } + if (state.kind !== "terminal") { + return { kind: "timed-out" as const, waitHandle: handle }; + } + return { + kind: state.state, + environmentId, + threadId: handle.threadId, + messageId: handle.messageId, + ...(state.turnId === undefined ? {} : { turnId: state.turnId }), + }; + }), ); }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..3b9d77fccf1a --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,61 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Schema from "effect/Schema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionTurnRequestCorrelation, + ProjectionTurnRequestCorrelationRepository, + type ProjectionTurnRequestCorrelationRepositoryShape, +} from "../Services/ProjectionTurnRequestCorrelations.ts"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, messageId: MessageId }), + Result: ProjectionTurnRequestCorrelation, + execute: ({ threadId, messageId }) => sql` + SELECT thread_id AS "threadId", message_id AS "messageId", turn_id AS "turnId", + state, requested_at AS "requestedAt", resolved_at AS "resolvedAt" + FROM projection_turn_request_correlations + WHERE thread_id = ${threadId} AND message_id = ${messageId} + LIMIT 1 + `, + }); + + const insertPending: ProjectionTurnRequestCorrelationRepositoryShape["insertPending"] = (row) => + sql` + INSERT INTO projection_turn_request_correlations + (thread_id, message_id, turn_id, state, requested_at, resolved_at) + VALUES (${row.threadId}, ${row.messageId}, NULL, 'pending', ${row.requestedAt}, NULL) + ON CONFLICT (thread_id, message_id) DO NOTHING + `.pipe(Effect.asVoid, Effect.mapError(toPersistenceSqlError("turnCorrelation.insertPending"))); + + const resolve: ProjectionTurnRequestCorrelationRepositoryShape["resolve"] = (row) => + sql` + UPDATE projection_turn_request_correlations + SET turn_id = ${row.turnId}, state = ${row.state}, resolved_at = ${row.resolvedAt} + WHERE thread_id = ${row.threadId} AND message_id = ${row.messageId} AND state = 'pending' + `.pipe(Effect.asVoid, Effect.mapError(toPersistenceSqlError("turnCorrelation.resolve"))); + + const get: ProjectionTurnRequestCorrelationRepositoryShape["get"] = (input) => + getRow(input).pipe(Effect.mapError(toPersistenceSqlError("turnCorrelation.get"))); + + const deleteByThreadId: ProjectionTurnRequestCorrelationRepositoryShape["deleteByThreadId"] = ({ + threadId, + }) => + sql`DELETE FROM projection_turn_request_correlations WHERE thread_id = ${threadId}`.pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("turnCorrelation.deleteByThreadId")), + ); + + return { insertPending, resolve, get, deleteByThreadId }; +}); + +export const ProjectionTurnRequestCorrelationRepositoryLive = Layer.effect( + ProjectionTurnRequestCorrelationRepository, + make, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 38bff8f5ca4c..96d92057bb29 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -57,6 +57,7 @@ import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadAnnotation.ts"; import Migration0043 from "./Migrations/043_UpdateDrain.ts"; import Migration0044 from "./Migrations/044_UpdateDrainClaim.ts"; +import Migration0045 from "./Migrations/045_ProjectionTurnRequestCorrelations.ts"; /** * Migration loader with all migrations defined inline. @@ -113,6 +114,7 @@ export const migrationEntries = [ [42, "ProjectionThreadAnnotation", Migration0042], [43, "UpdateDrain", Migration0043], [44, "UpdateDrainClaim", Migration0044], + [45, "ProjectionTurnRequestCorrelations", Migration0045], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts new file mode 100644 index 000000000000..69245dd14d4e --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts @@ -0,0 +1,48 @@ +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionTurnRequestCorrelationRepositoryLive } from "../Layers/ProjectionTurnRequestCorrelations.ts"; +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../Services/ProjectionTurnRequestCorrelations.ts"; + +const layer = it.layer( + ProjectionTurnRequestCorrelationRepositoryLive.pipe( + Layer.provideMerge(NodeSqliteClient.layerMemory()), + ), +); + +layer("045_ProjectionTurnRequestCorrelations", (it) => { + it.effect("inserts once, resolves once, and deletes by owning thread", () => + Effect.gen(function* () { + yield* runMigrations(); + const repository = yield* ProjectionTurnRequestCorrelationRepository; + const key = { threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1") }; + yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:00.000Z" }); + yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:01.000Z" }); + yield* repository.resolve({ + ...key, + turnId: TurnId.make("turn-1"), + state: "started", + resolvedAt: "2026-08-22T00:00:02.000Z", + }); + yield* repository.resolve({ + ...key, + turnId: null, + state: "error", + resolvedAt: "2026-08-22T00:00:03.000Z", + }); + const resolved = yield* repository.get(key); + assert.strictEqual(resolved._tag, "Some"); + if (resolved._tag === "Some") { + assert.strictEqual(resolved.value.state, "started"); + assert.strictEqual(resolved.value.turnId, "turn-1"); + assert.strictEqual(resolved.value.requestedAt, "2026-08-22T00:00:00.000Z"); + } + yield* repository.deleteByThreadId({ threadId: key.threadId }); + assert.strictEqual((yield* repository.get(key))._tag, "None"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..ce3b61c81113 --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_turn_request_correlations ( + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + turn_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'error', 'interrupted')), + requested_at TEXT NOT NULL, + resolved_at TEXT, + PRIMARY KEY (thread_id, message_id) + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..1c4afb44bcc1 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,43 @@ +import { IsoDateTime, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionTurnRequestCorrelation = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + turnId: Schema.NullOr(TurnId), + state: Schema.Literals(["pending", "started", "error", "interrupted"]), + requestedAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionTurnRequestCorrelation = typeof ProjectionTurnRequestCorrelation.Type; + +export interface ProjectionTurnRequestCorrelationRepositoryShape { + readonly insertPending: ( + row: Pick, + ) => Effect.Effect; + readonly resolve: ( + row: Pick< + ProjectionTurnRequestCorrelation, + "threadId" | "messageId" | "turnId" | "state" | "resolvedAt" + >, + ) => Effect.Effect; + readonly get: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect; +} + +export class ProjectionTurnRequestCorrelationRepository extends Context.Service< + ProjectionTurnRequestCorrelationRepository, + ProjectionTurnRequestCorrelationRepositoryShape +>()( + "t3/persistence/Services/ProjectionTurnRequestCorrelations/ProjectionTurnRequestCorrelationRepository", +) {} diff --git a/apps/server/src/provider/CodexThreadTool.test.ts b/apps/server/src/provider/CodexThreadTool.test.ts index d05d7c5e89dc..48229520b550 100644 --- a/apps/server/src/provider/CodexThreadTool.test.ts +++ b/apps/server/src/provider/CodexThreadTool.test.ts @@ -43,7 +43,7 @@ it("renders an ordinary Node-hosted wrapper pinned to its owning home", () => { stateDir: "/srv/lastcode home/userdata", electronRunAsNode: false, }), - "#!/bin/sh\ncase \"$1\" in\n current|list|read|send) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", + "#!/bin/sh\ncase \"$1\" in\n current|list|read|send|wait) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", ); }); @@ -191,7 +191,7 @@ it.effect("routes pinned flags through each real thread leaf parser", () => ), electronRunAsNode: "0", }); - for (const command of ["current", "list", "read", "send"] as const) { + for (const command of ["current", "list", "read", "send", "wait"] as const) { const output = yield* Effect.tryPromise( () => new Promise((resolve, reject) => { @@ -203,10 +203,10 @@ it.effect("routes pinned flags through each real thread leaf parser", () => ); assert.match(output, new RegExp(`t3 thread ${command}`)); } - const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["wait"], { + const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["future-command"], { encoding: "utf8", }); assert.strictEqual(unsupported.status, 64); - assert.match(unsupported.stderr, /unsupported command 'wait'/); + assert.match(unsupported.stderr, /unsupported command 'future-command'/); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), ); diff --git a/apps/server/src/provider/CodexThreadTool.ts b/apps/server/src/provider/CodexThreadTool.ts index 461923c15eca..e289258352be 100644 --- a/apps/server/src/provider/CodexThreadTool.ts +++ b/apps/server/src/provider/CodexThreadTool.ts @@ -37,7 +37,7 @@ export function renderCodexThreadToolWrapper(input: CodexThreadToolInvocation): "thread", ].join(" "); const pinnedFlags = `--base-dir ${shellQuote(input.baseDir)} --state-dir ${shellQuote(input.stateDir)}`; - return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read|send) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; + return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read|send|wait) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; } export const materializeCodexThreadTool = Effect.fn("materializeCodexThreadTool")( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..e4ac9882a8c7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -472,6 +472,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const orchestrationEngine = { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), latestSequence: Effect.succeed(0), @@ -664,6 +666,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), latestSequence: Effect.succeed(0), diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 485cd5bb08a4..34be13c19441 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -82,6 +82,8 @@ const runReconciliation = (input: { Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: input.dispatch, streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), @@ -287,6 +289,8 @@ it.effect("does not fail startup when the live provider session inventory cannot }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.die("unused"), streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..e33ba3ceed9e 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -165,6 +165,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -210,6 +212,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -261,6 +265,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md index ac1d6ad2099f..a0158aedd244 100644 --- a/docs/user/codex-thread-tools.md +++ b/docs/user/codex-thread-tools.md @@ -9,6 +9,8 @@ lastcode-thread current --json lastcode-thread list --json lastcode-thread read --turn-limit 5 --json lastcode-thread send --message --json +lastcode-thread send --message --wait --timeout '10 minutes' --json +lastcode-thread wait '' --timeout '10 minutes' --json ``` Thread IDs are resolved locally. An exact ID always wins; a prefix must identify exactly one @@ -31,6 +33,15 @@ confirms that LastCode persisted the request, not that the provider finished it. oversized messages, missing or ambiguous targets, authorization failures, and rejected dispatches fail without reporting acceptance. +Add `--wait` when the caller needs the exact resulting turn rather than dispatch acceptance. +LastCode emits one `LASTCODE_WAIT_HANDLE=` recovery line on stderr before the +long wait, then prints one final JSON result on stdout. A completed result includes the exact +turn ID and a response bounded to 64,000 characters. Timeouts do not interrupt the target; +their nested `waitHandle` can be passed back to `lastcode-thread wait`. A +`transport-unknown` or `dispatch-unknown` result also preserves that handle without claiming +whether the request completed or, for dispatch, whether acceptance was observed. Plain `send` +does not create wait state and its accepted JSON cannot be used as a wait handle. + The bundled thread command is currently available on POSIX Node hosts and packaged macOS. Windows and packaged Linux AppImage Codex sessions still receive LastCode thread and home identity, but do not receive a `lastcode-thread` launcher. Windows has no POSIX launcher; @@ -43,6 +54,7 @@ wrapper stored in its LastCode home: ssh ~/.lastcode/userdata/bin/lastcode-thread list --json ssh ~/.lastcode/userdata/bin/lastcode-thread read --json ssh ~/.lastcode/userdata/bin/lastcode-thread send --message --json +ssh ~/.lastcode/userdata/bin/lastcode-thread wait '' --json ``` `~/.lastcode` is the default home. If that host uses a custom LastCode home, use its explicit diff --git a/packages/contracts/src/environmentHttp.test.ts b/packages/contracts/src/environmentHttp.test.ts index 4cd39074f3ea..b48657845f5d 100644 --- a/packages/contracts/src/environmentHttp.test.ts +++ b/packages/contracts/src/environmentHttp.test.ts @@ -7,7 +7,9 @@ import { EnvironmentRequestInvalidError, EnvironmentResourceNotFoundError, EnvironmentScopeRequiredError, + ThreadWaitHandle, } from "./environmentHttp.ts"; +import * as Schema from "effect/Schema"; const traceId = "trace-1"; @@ -60,3 +62,21 @@ describe("environment HTTP errors", () => { }); }); }); + +it("decodes strict compact wait handles", () => { + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(ThreadWaitHandle)); + const value = decode( + '{"kind":"wait-handle","environmentId":"env-1","threadId":"thread-1","messageId":"message-1"}', + ); + expect(value.kind).toBe("wait-handle"); + expect(() => + decode( + '{"kind":"accepted","environmentId":"env-1","threadId":"thread-1","messageId":"message-1"}', + ), + ).toThrow(); + expect(() => + decode( + '{"kind":"wait-handle","environmentId":"env-1","threadId":"thread-1","messageId":"message-1","extra":true}', + ), + ).toThrow(); +}); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e7494862251e..8fef518d49bc 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,7 +24,14 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + AuthSessionId, + EnvironmentId, + MessageId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ClientOrchestrationCommand, @@ -62,6 +69,7 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "invalid_scope", "scope_not_granted", "invalid_command", + "wrong_environment", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; @@ -184,7 +192,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass()( @@ -497,6 +508,43 @@ const EnvironmentOrchestrationThreadSnapshotQuery = { beforeCursor: Schema.optional(TrimmedNonEmptyString), }; +export const ThreadWaitHandle = Schema.Struct({ + kind: Schema.Literal("wait-handle"), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type ThreadWaitHandle = typeof ThreadWaitHandle.Type; + +export const ThreadWaitResult = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("completed"), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + turnId: TurnId, + response: Schema.String, + responseTruncated: Schema.Boolean, + }), + Schema.Struct({ + kind: Schema.Literals(["error", "interrupted"]), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + turnId: Schema.optional(TurnId), + }), + Schema.Struct({ kind: Schema.Literal("timed-out"), waitHandle: ThreadWaitHandle }), +]); +export type ThreadWaitResult = typeof ThreadWaitResult.Type; + +const ThreadWaitInput = Schema.Struct({ + waitHandle: ThreadWaitHandle, + timeoutMs: Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: 600_000 }), + ), +}); + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -528,6 +576,19 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr success: DispatchResult, error: EnvironmentOrchestrationDispatchErrors, }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("waitThread", "/api/orchestration/thread-wait", { + headers: OptionalBearerHeaders, + payload: ThreadWaitInput, + success: ThreadWaitResult, + error: [ + EnvironmentRequestInvalidError, + EnvironmentScopeRequiredError, + EnvironmentResourceNotFoundError, + EnvironmentInternalError, + ], + }).middleware(EnvironmentAuthenticatedAuth), ) {} /** Large, compressible pull-request payloads travel over HTTP rather than the RPC socket. */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 010c7c0ab182..c185fb7b3ee6 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -24,9 +24,11 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + ThreadTurnRequestOutcome, isProviderSendTurnSupportedImageMimeType, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TurnId } from "./baseSchemas.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); @@ -38,6 +40,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, ); +const decodeThreadTurnRequestOutcome = Schema.decodeUnknownEffect(ThreadTurnRequestOutcome); const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); @@ -81,6 +84,31 @@ it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => }), ); +it.effect("accepts only explicit tracked correlation markers and typed request outcomes", () => + Effect.gen(function* () { + const payload = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-1", + messageId: "message-1", + runtimeMode: "full-access", + interactionMode: "default", + trackRequestCorrelation: true, + createdAt: "2026-08-22T00:00:00.000Z", + }); + assert.strictEqual(payload.trackRequestCorrelation, true); + const invalid = yield* Effect.result( + decodeThreadTurnStartRequestedPayload({ ...payload, trackRequestCorrelation: false }), + ); + assert.strictEqual(invalid._tag, "Failure"); + assert.deepStrictEqual( + yield* decodeThreadTurnRequestOutcome({ kind: "started", turnId: TurnId.make("t") }), + { + kind: "started", + turnId: TurnId.make("t"), + }, + ); + }), +); + it.effect("parses turn diff input with whitespace ignoring enabled", () => Effect.gen(function* () { const parsed = yield* decodeTurnDiffInput({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 40fa8b03c8f7..525be4fadedc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -961,6 +961,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, }); @@ -980,6 +981,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, }); @@ -1164,6 +1166,25 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +export const ThreadTurnRequestOutcome = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("started"), turnId: TurnId }), + Schema.Struct({ + kind: Schema.Literal("terminal"), + state: Schema.Literals(["error", "interrupted"]), + completedAt: IsoDateTime, + }), +]); +export type ThreadTurnRequestOutcome = typeof ThreadTurnRequestOutcome.Type; + +const ThreadTurnRequestResolveCommand = Schema.Struct({ + type: Schema.Literal("thread.turn-request.resolve"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + outcome: ThreadTurnRequestOutcome, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -1173,6 +1194,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadTurnRequestResolveCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1205,6 +1227,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.interaction-mode-set", "thread.message-sent", "thread.turn-start-requested", + "thread.turn-request-resolved", "thread.turn-interrupt-requested", "thread.approval-response-requested", "thread.user-input-response-requested", @@ -1389,9 +1412,16 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), ), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, }); +export const ThreadTurnRequestResolvedPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + outcome: ThreadTurnRequestOutcome, +}); + export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, turnId: Schema.optional(TurnId), @@ -1599,6 +1629,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.turn-start-requested"), payload: ThreadTurnStartRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-request-resolved"), + payload: ThreadTurnRequestResolvedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-interrupt-requested"), From 73892c80967371e05c125f63420b23bc9d191080 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 02:47:05 -0700 Subject: [PATCH 05/23] fix(lastcode): wait for finalized thread replies --- apps/server/src/bin.test.ts | 17 ++++++++++++++--- .../Layers/TurnRequestWaitQuery.ts | 9 +++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1521ad5f98c..8889ed0a885c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -880,6 +880,8 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { } const trackedEvents = yield* engine.subscribeDomainEvents; const composedTurnId = TurnId.make("turn-composed-wait"); + const spilledResponse = "s".repeat(24_001); + const responseTail = " exact buffered tail"; const responder = yield* trackedEvents.pipe( Stream.filter( (event) => @@ -904,6 +906,15 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { outcome: { kind: "started", turnId: composedTurnId }, createdAt: responseAt, }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-composed-wait-spill"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + delta: spilledResponse, + turnId: composedTurnId, + createdAt: responseAt, + }); yield* engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-composed-wait-running"), @@ -943,10 +954,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { ); yield* engine.dispatch({ type: "thread.message.assistant.delta", - commandId: CommandId.make("cmd-composed-wait-answer"), + commandId: CommandId.make("cmd-composed-wait-tail"), threadId, messageId: MessageId.make("message-composed-wait-answer"), - delta: "Composed exact answer", + delta: responseTail, turnId: composedTurnId, createdAt: responseAt, }); @@ -1006,7 +1017,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { threadId, messageId: composedMessageId, turnId: composedTurnId, - response: "Composed exact answer", + response: `${spilledResponse}${responseTail}`, responseTruncated: false, }); const trackedTurnId = TurnId.make("turn-live-wait"); diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 441c6195fbde..dbc510782f7e 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -18,6 +18,7 @@ const WaitRow = Schema.Struct({ turnState: Schema.NullOr(Schema.Literals(["running", "completed", "error", "interrupted"])), assistantMessageId: Schema.NullOr(MessageId), response: Schema.NullOr(Schema.String), + responseStreaming: Schema.NullOr(Schema.Number), }); export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { @@ -27,7 +28,7 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { execute: ({ threadId, messageId }) => sql` SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", - messages.text AS "response" + messages.text AS "response", messages.is_streaming AS "responseStreaming" FROM projection_turn_request_correlations AS correlations LEFT JOIN projection_turns AS turns ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id @@ -53,7 +54,11 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { } if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { if (value.turnState === "completed") { - if (value.assistantMessageId === null || value.response === null) { + if ( + value.assistantMessageId === null || + value.response === null || + value.responseStreaming !== 0 + ) { return { kind: "pending" } as const; } return { From 741868128dc73004a676430dec3acce41d34b904 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 02:47:06 -0700 Subject: [PATCH 06/23] docs(lastcode): remove completed implementation plan --- docs/lastcode/codex-thread-tools-plan.md | 490 ----------------------- 1 file changed, 490 deletions(-) delete mode 100644 docs/lastcode/codex-thread-tools-plan.md diff --git a/docs/lastcode/codex-thread-tools-plan.md b/docs/lastcode/codex-thread-tools-plan.md deleted file mode 100644 index da07b44a5651..000000000000 --- a/docs/lastcode/codex-thread-tools-plan.md +++ /dev/null @@ -1,490 +0,0 @@ -# Codex Thread Tools Plan - -## Outcome - -Give Codex an official, small LastCode-provided command for identifying its current -LastCode thread, inspecting another thread, sending that thread a user-directed -message, and waiting for the exact resulting turn when the user asks a question. - -The feature is intentionally temporary and LastCode-only. It serves one user running -a handful of concurrent Codex threads across a few personally administered hosts. It -does not establish a general multi-agent orchestration platform. - -## Source of Truth and Prior Art - -This file is the source of truth for scope, sequencing, acceptance, and validation. -The implementation should reuse ideas, but not wholesale branches, from: - -- upstream T3 Code PR #2829, especially its `current/list/read/send/wait` MCP tool - semantics; -- upstream T3 Code PR #3004, especially server-authoritative current thread and - workspace identity; -- the existing `t3` CLI's authenticated live-server and offline projection pattern in - `apps/server/src/cli/project.ts`; and -- the existing LastCode shell snapshot, bounded thread detail, dispatch, event stream, - and projected turn/message correlation. - -PR #6573 is not the implementation base. Its V1 `list/send` behavior is narrower than -the read and exact-wait behavior required here. - -## Product Contract - -Codex receives a discoverable `lastcode-thread` executable in its `PATH`. The command -also remains reachable through the bundled `t3 thread` command tree so the wrapper has -no business logic of its own. - -The initial command surface is: - -```text -lastcode-thread current --json -lastcode-thread list --json -lastcode-thread read [--turn-limit ] --json -lastcode-thread send --message --json -lastcode-thread wait '' [--timeout ] --json -lastcode-thread send --message --wait [--timeout ] --json -``` - -Human-readable output may be added where it is essentially free, but stable JSON is -the primary Codex interface. Every successful result includes a scoped identity with -`environmentId` and `threadId`; current-thread output also includes project, workspace, -provider instance, and provider-native Codex thread identity when available. - -Commands that accept a plain thread ID always address the server on which the command -is running. Cross-host addressing is performed by choosing the SSH host first, not by -embedding an environment selector in a local command. Wait handles carry and validate -the environment ID so they cannot be resumed against the wrong server. - -Codex performs cross-host lookup by running the same command through the user's -existing SSH aliases. The command itself is local-only. A normal lookup is: - -```text -local lastcode-thread read - -> if absent, ssh ~/.lastcode/userdata/bin/lastcode-thread read -``` - -There is no LastCode host registry, SSH configuration parser, credential delegation, -desktop connection-catalog dependency, or server-to-server transport in this plan. -The example path is the default LastCode home; a host configured with a custom base -directory uses that explicit home's `userdata/bin/lastcode-thread` path. - -Plain `send` returns `{ kind: accepted, environmentId, threadId, messageId }` and does -not enable later waiting. Only `send --wait` marks a request for correlation. Its -`timed-out`, `transport-unknown`, and `dispatch-unknown` outcomes include an opaque -wait handle containing its scoped thread -identity and new message ID: -`{ kind: wait-handle, environmentId, threadId, messageId }`. A timed-out result nests -that object as `{ kind: timed-out, waitHandle }`; `wait` accepts the nested handle -object from any of those outcomes, serialized as compact JSON and passed as one -shell-quoted UTF-8 argument, never the plain accepted result. The decoder accepts -exactly the typed handle schema and -rejects extra/missing fields. A timed-out handle can be -passed back to `wait`, which resumes waiting for that specific message's projected -provider turn and resulting assistant response. It never interprets an arbitrary newer -turn as the answer. - -`read` and successful `wait` CLI output use a 64,000-character presentation budget. -For `read`, message text and activity summaries share that budget. Unresolved approval and -user-input request explanations are retained first, then the newest content fills the remaining -text budget. Activity records also have a conservative cap: unresolved requests are pinned -first and the remaining slots contain the newest activity, all in their original deterministic -order. JSON includes -`textTruncated` and `originalTextChars` when selected text exceeds the budget, plus -additive activity-count truncation metadata when the record cap applies; metadata and -identifiers are never truncated. The live server may hydrate its existing bounded-turn detail -snapshot before the CLI applies this output bound; this temporary local transport does -not add a second text-limited SQL/query stack. - -## Deliberate Constraints - -- Codex only. No Claude, Cursor, Grok, OpenCode, or generic provider abstraction. -- User-directed use only. No autonomous coordinator mode, recursively delegated - workflows, or background callback scheduler. -- Expected scale is fewer than ten threads and a few hosts. Prefer bounded linear - scans and one request per inspected host over caches, brokers, queues, or indexes. -- No UI changes on web, desktop, or mobile. -- No MCP dependency and no separate daemon. -- No thread creation, interrupt, fork, merge, worktree handoff, or scheduled task - commands. -- No content search or standalone resolver command in the first version. `list` plus - exact or unambiguous ID-prefix resolution inside `read` and `send` is sufficient. -- No fuzzy resolution. Ambiguous prefixes fail closed and return candidates. -- No compatibility layer for pre-feature binaries or schemas. This is a single-stack - LastCode feature and may evolve with its caller. -- No direct SQL in the wrapper or Codex instructions. Server-owned CLI/query services - own persistence details. -- No offline mutation. Read commands may use the existing offline projection fallback; - `send` and `wait` require the live owning LastCode server. - -## Slice and Pull Request Stack - -The umbrella branch is `lastcode/codex-thread-tools`, opened against -`lastcode/main`. Its first commit is this reviewed plan. The umbrella PR remains open -until the user explicitly rubberstamps the assembled feature. - -Each serial slice starts from the latest umbrella head, opens a PR targeting the -umbrella branch, and is squash-merged into the umbrella only after its own validation -and review gates pass. Later slices are created after the preceding squash merge so -they never retain obsolete pre-squash ancestry. - -### Slice 1: Codex identity and read-only inspection - -Branch: `lastcode/codex-thread-read` - -1. Add a `t3 thread` command group and `current --json` using the existing Effect CLI - patterns. Reuse or extract the CLI runner that resolves the active home, discovers a - live server, issues and revokes a short-lived local session, calls the typed - authenticated HTTP API, and falls back to projection queries for offline reads. - Issue only the minimum orchestration-read scope and revoke it on success, failure, - interruption, or timeout; do not copy the project command's administrative scope. -2. Materialize a tiny runtime wrapper under the active T3/LastCode state directory and - prepend that bin directory only to Codex provider processes. -3. Pass the authoritative LastCode thread ID and active home through the Codex - adapter/runtime input and inject only `T3CODE_THREAD_ID` and `T3CODE_HOME` into the - Codex process. `current` derives environment, project, workspace, and provider - metadata from that thread's server-owned shell/detail state and the existing - environment-descriptor endpoint. Preserve `CODEX_THREAD_ID` as a separately named - provider-native identity when available; never conflate it with the LastCode thread - ID. -4. Make the wrapper pin its owning home explicitly on every invocation. For an ordinary - Node-hosted server it executes that server's runtime and bundled CLI entry with - `--base-dir `. For packaged macOS LastCode it executes the LastCode - binary while preserving inherited `ELECTRON_RUN_AS_NODE=1`, the bundled server CLI - entry, and the same explicit base directory. The wrapper contains invocation details - only and delegates all behavior to `t3 thread`. Windows and packaged Linux AppImage - hosts are out of scope for the POSIX wrapper; AppImage executable and app-resource - paths live under a transient mount. Codex still receives LastCode identity variables - on those hosts, but no thread command is added to PATH. -5. Add bounded `list` and `read` commands over the existing shell and thread-detail - snapshots. `read` accepts an exact or unambiguous thread-ID prefix and returns - a small deterministic candidate subset plus original-count truncation metadata when - resolution is ambiguous. `list` returns at most 50 deterministically ordered threads - and reports truncation plus the original thread count when that bound is exceeded. -6. Default `read` to a small recent-turn window and impose a conservative maximum. - Include thread status, project/workspace/branch, recent turns, and transcript - content needed to answer “what is this thread up to?” without dumping the full - database. -7. For offline transcript reads, call the bounded thread-detail projection query - directly rather than the command read model, which intentionally omits hydrated - thread bodies. Compose only the SQLite persistence and projection snapshot-query - layers through a read-only database client that skips WAL setup and migrations; - derive the selected home/state paths without provisioning directories or trace files, - and do not probe or reserve a server port. Offline inspection must not start the - writable orchestration engine or its projectors alongside a live server. -8. Preserve lifecycle visibility for active snoozed, settled, pending-input, and - working threads. Do not mutate those states. Archived and deleted threads are out - of scope and return not found. -9. Document the supported local-first/SSH composition for Codex. Do not add host - discovery code. -10. Add focused tests for new/resumed Codex identity propagation, environment injection, - active-home selection, wrapper/desktop/SSH invocation, live-server and offline-detail - reads, least-privilege authorization cleanup, bounds, ambiguity, not-found, - lifecycle state, JSON schema, and missing current context. - -Acceptance: - -- `current` identifies the exact environment, LastCode thread, project, workspace, and - provider identity without SQLite/transcript heuristics, and non-Codex providers are - unchanged. -- Codex can list a small host's threads and inspect a supplied exact or unique-prefix - ID through the supported command. -- The same command works when invoked explicitly over SSH on another LastCode host. -- Read operations never wake, unsnooze, unsettle, or otherwise modify a target thread. - -### Slice 2: User-directed tell/send - -Branch: `lastcode/codex-thread-send` - -1. Add live-server-only `send` using the existing authenticated orchestration dispatch - endpoint and `thread.turn.start` command. Issue only the orchestration-read and - orchestration-operate scopes needed for target lookup and dispatch, and revoke them - on every exit path. -2. Generate and retain the new user message ID before dispatch. Return - `{ kind: accepted, environmentId, threadId, messageId }`; this is deliberately not a - wait handle and cannot be passed to `wait`. -3. Resolve the target from the current shell snapshot and use its existing runtime and - interaction settings rather than inventing defaults. -4. Bound message text with the existing provider send-turn input limit and reject an - oversized message before dispatch. -5. Reject deleted or missing local targets with typed errors. Let the existing decider - remain authoritative for lifecycle and concurrency validity rather than duplicating - orchestration rules in the CLI. -6. Add focused tests for successful dispatch, exact command payload, least-privilege - scope issuance and cleanup, live-server requirement, invalid target, oversized - input, decider rejection, exact/prefix ambiguity, and accepted-result encoding. - -Acceptance: - -- A user can tell Codex “Tell THREAD_ID to ...”, and Codex can dispatch the instruction - to that exact local or explicitly SSH-addressed thread. -- The command reports accepted persistence, not successful completion. -- Dispatch failures are explicit and never reported as accepted. - -### Slice 3: Exact ask/wait - -Branch: `lastcode/codex-thread-wait` - -1. Extend CLI `send` with `trackRequestCorrelation: true` only for `send --wait`. - Standalone `wait` accepts a strict `kind: wait-handle` object previously returned by - a timed-out, transport-unknown, or dispatch-unknown `send --wait`/`wait` outcome. Add - the - optional literal field to the client turn-start command, normalized internal - command, and turn-start-requested payload; preserve it through the normalizer and - decider. Absence means current plain-send/UI behavior. - Generate stable command and message IDs before dispatch. If the dispatch response is - lost, retry once with the same IDs so the engine's command receipt deduplication can - return the original acceptance. If the response remains ambiguous, return - `{ kind: dispatch-unknown, waitHandle }` without claiming acceptance; `wait` either - finds the persisted correlation or returns correlation-not-found. -2. Add one narrow `projection_turn_request_correlations` table keyed by - `{threadId, messageId}`, with nullable `turnId`, `state = pending | started | error | -interrupted`, `requestedAt`, and `resolvedAt`. Project every existing - correlation-tracked `thread.turn-start-requested` event into an idempotent pending - row. The tracking marker is carried from the CLI's start command to its request - event; events from before this feature and ordinary UI starts do not create rows. - Absence of the marker is covered by a negative projection test. - Do not change the current single-pending-row behavior, ingestion assumptions, - cursor, or pagination of `projection_turns`. - Delete a thread's correlation rows when that thread is deleted; otherwise retain the - small per-tracked-request history with the thread and add no cleanup scheduler. -3. For marked requests only, keep the originating message ID in the - `ProviderCommandReactor` closure that already - receives `thread.turn-start-requested`; do not widen generic provider start - contracts with a LastCode message ID. Funnel every pre-turn exit—missing/deleted - context, missing/invalid message, request construction, provider start error, and - interruption—through one finalization helper. -4. Add one internal `thread.turn-request.resolve` command and - `thread.turn-request-resolved` event with `{threadId, messageId, outcome}`, where - outcome is either `{ kind: started, turnId }` or - `{ kind: terminal, state: error | interrupted, completedAt }`. Derive its command ID - deterministically only from the originating `thread.turn-start-requested` event ID, - not outcome fields or timestamps, so exactly one resolution can persist and reactor - retry/replay is idempotent. Add the schemas to the existing - orchestration unions, but keep it out of the public web/mobile thread-detail stream; - it is internal bookkeeping, not user-visible activity. Its decider path validates - the correlation identifiers but does not require the target thread to still exist, - so a deletion race can resolve or harmlessly no-op the row. -5. Project `thread.turn-request-resolved` through one transactional, idempotent - repository operation on the correlation table. Started outcomes set the exact - `turnId`; pre-turn terminal outcomes set `error` or `interrupted`. It never changes - `projection_turns`, overwrites an existing terminal outcome, or associates one - message with two turns. Resolution is update-only and no-ops when deletion already - removed the keyed row, so late resolution cannot recreate deleted state. Public JSON - uses `error`, not `failed`. - The reactor finalizer is one-shot: whichever `started`, `error`, or `interrupted` - outcome first persists for the deterministic request command wins; later competing - calls deduplicate. Repeated finalizer calls may carry different timestamps without - changing identity. -6. Add a typed authenticated HTTP wait endpoint. Its success schema is a tagged outcome - union for `completed`, `error`, `interrupted`, and `timed-out`; timeout and terminal - outcomes are HTTP successes, while - typed thread-not-found, correlation-not-found, wrong-environment, query, and existing - authorization errors map to explicit route errors/statuses. A deleted thread returns - thread-not-found immediately; an existing thread with an invalid or unprojected - handle returns correlation-not-found. Give it a conservative - server-side maximum duration and a separate CLI timeout appropriate for a long-held - wait. Set the CLI transport deadline beyond the requested server deadline so the - tagged `timed-out` response normally wins; do not inherit the existing one-second - project-command timeout. If the local transport deadline or connection fails first, - return `{ kind: transport-unknown, waitHandle }` without claiming that the server - timed out or that the turn is unfinished, then revoke the read-only credential. The - command issues only orchestration-read scope and revokes it on every exit path. - Only `completed` guarantees `turnId` and bounded assistant response text. Pre-turn - `error` or `interrupted` outcomes omit both; post-start terminal outcomes may include - `turnId` but do not invent assistant text. -7. Extract the existing WebSocket subscription's buffered subscribe-before-read race - pattern into a small internal helper that accepts its own event predicate. Use it for - wait without exposing the correlation event to the public thread stream: subscribe - to the owning thread's raw domain events before the initial correlation/turn - projection read, then re-read only when relevant events arrive. Do not sleep or poll. -8. Once correlation supplies a turn ID, read terminal state and assistant response from - the existing exact turn/thread projections. If the exact turn row is not projected - yet, remain subscribed and re-read on that thread's session/turn events; cover both - correlation-first and runtime-projection-first orderings. Treat completed, error, - and interrupted distinctly. - Timeout returns a resumable wait handle and does not interrupt the target. -9. Validate the wait handle's environment ID against the local server, then add `wait` - and `send --wait` composition. On success, return the exact turn identity, - terminal state, and completed assistant response correlated to the sent message. - `send --wait` revokes its read/operate dispatch credential immediately after accepted - persistence, writes exactly one recovery line to stderr as - `LASTCODE_WAIT_HANDLE=` without adding another stdout record, - then issues a separate read-only credential for the long wait; operate privilege is - never retained across waiting, timeout, interruption, or resume. User interruption - may end the final result stream, but the already-emitted handle remains available. - The recovery line is a machine-readable framing convention, not a shell assignment - to `eval` or `source`; Codex extracts the JSON value and passes it back as one quoted - argument. -10. Add focused migration/repository, command/event union, decider, - reactor-finalization, transactional - projection-ordering, route, and CLI tests for immediate completion, - pending-before-provider failure, pending-to-running-to-completed, - failure/interruption, timeout-and-resume, event-before-subscribe race protection, - unrelated thread/turn events, wrong-environment handles, concurrent UI/tool sends, - thread-deletion cleanup, and server restart after the correlation event is durable. - A restart before provider adoption—or in the narrow interval after provider - acceptance but before the correlation outcome persists—exercises the existing V1 - limitation: wait times out with the same resumable handle and does not claim - completion or retry automatically. Closing that external-side-effect durability gap - is explicitly outside this temporary feature. Also test distinct plain-accepted and - timed-out-handle encoding, wait-after-thread-deletion, missing correlation, duplicate - finalization, competing outcomes, absent tracking, long-wait timeout configuration, - least-privilege authorization cleanup, and bounded response output. - Cover lost dispatch response with same-command retry and `dispatch-unknown`, plus - interruption after accepted persistence with early handle emission and credential - cleanup. Deterministically force a wait connection/deadline failure and assert - `transport-unknown`, handle recovery, credential cleanup, exactly one recovery line - on stderr, and exactly one final JSON object on stdout. - -Acceptance: - -- “Ask THREAD_ID ...” can send one message and wait for the exact resulting turn. -- An unrelated newer turn can never be mistaken for the requested answer. -- A known but unresolved correlation times out visibly; an invalid or unprojected - handle returns correlation-not-found. Neither is guessed from a newer turn. -- “Do this, and when finished tell THREAD_ID ...” needs no workflow engine: Codex runs - its local work and then calls `send`. - -## Validation and Review - -### Plan gate - -- Run up to ten full plan-review rounds with Luna at high reasoning over `basic`, - `best-practices`, and `KISS` lenses; skip the UI/component lens because the plan has - no UI surface. -- Stop early when every applicable lens is quiet. Reopen a quiet lens if a later review - materially changes scope, architecture, validation, or acceptance. -- Record applied, defended, and deferred findings in this file. - -### Slice gate - -For every slice: - -1. Implement with a Sol-medium subagent on the slice branch. -2. Run the smallest focused tests, formatting/lint checks, and affected package - typechecks required by the slice. -3. Run up to five Luna-high implementation-review rounds over correctness, KISS, and - repository best practices; add UX only where command behavior warrants it and skip - UI/component review. -4. Apply or concretely defend every finding, rerun affected validation, and require all - applicable lenses to become quiet. -5. Push the exact reviewed head, open the slice PR against the umbrella branch, inspect - all current-head comments/reviews/checks and unresolved threads, and request Codex - review if available. -6. Squash-merge only with an explicit exact-head match after focused validation and - review gates are clean. - -The repository's `pnpm lastcode:merge` command intentionally rejects PRs whose base is -not `lastcode/main`, so it cannot merge slice PRs. For each slice, perform the same -open/non-draft/base/head/mergeability/unresolved-thread checks manually and use GitHub's -`--match-head-commit` squash merge. Do not run the nightly checkpoint trigger for slice -merges. - -### Umbrella gate - -After all slices are merged into the umbrella: - -- run focused end-to-end CLI tests for `current`, `list/read`, `send`, and exact - `wait`; -- run `vp check`, `vp run typecheck`, `git diff --check`, and `pnpm lastcode:ci` on the - exact clean umbrella head against the fetched `origin/lastcode/main` base; -- run a final Luna-high assembled-stack review if slice merges or integration fixes - materially changed cross-slice behavior; -- report the exact umbrella head, validation, review state, unresolved-thread count, - and remaining risks; and -- leave the umbrella PR open. Do not run `pnpm lastcode:merge` until the user explicitly - rubberstamps it. - -Manual app QA is not required because this is a backend/CLI-only feature. A bounded -command-level smoke test may use a disposable LastCode home; never run a server against -the user's live `~/.lastcode/userdata` database. - -The user's explicit request to implement and babysit this stack invokes the -`implement-plan` and guarded LastCode delivery workflows and authorizes their final -repo-wide validation commands despite the repository's normal focused-check default. - -## Review Record - -Requested depth: up to 10 rounds with Luna at high reasoning. Review stopped after -round 7 because every applicable lens was quiet. The UI/component lens was skipped -because the plan has no UI surface. Across all rounds, 58 findings were applied, 6 were -defended to preserve the user's explicit scope or delivery workflow, and 0 were -deferred. Intermediate designs mentioned below were superseded by later KISS rounds; -the product contract and three slices above are the implementation source of truth. - -- Round 1 `basic`: six findings applied. The plan dropped archived reads, minimized - provider identity injection, named the offline detail-query path, clarified local - thread addressing, specified the wait HTTP/timeout contract, and retained durable - message correlation for turns that fail before receiving a provider turn ID. -- Round 1 `best-practices`: eight findings applied. The plan now pins the wrapper's - owning home and packaged invocation, limits the first version to POSIX Node hosts and packaged macOS, - uses least-privilege command scopes, bounds send input, carries exact message - correlation through provider-start outcomes, rejects overlapping tool sends, uses - existing terminal-state vocabulary, and reports pre-adoption restart orphaning - without adding automatic recovery. -- Round 1 `KISS`: three findings applied and two defended. The plan removed the - standalone `find` command and wait-handle versioning, and requires reuse of the - existing buffered subscription race pattern. The explicit user-requested review - budgets and final implement-plan/full-CI gate remain; they stop early when quiet and - guard the exact cross-thread behavior that would otherwise be difficult to diagnose. -- Round 2 `basic`: five findings applied. Exact waits now use per-message pending - projection rows and explicit reactor correlation outcomes, `current` names the - environment descriptor as its identity source, `send --wait` accepts the same - exact-or-prefix target as `send`, and transcript/answer output has a concrete - truncation contract. -- Round 2 `best-practices`: six findings applied and one defended. The plan now removes - arbitrary pending-message adoption, defines one deterministic correlation - command/event and a single reactor finalizer, requires atomic order-independent - projection, specifies the HTTP outcome/error union, and uses one fake-clock-testable - adoption deadline. The 64k limit remains a CLI/SSH presentation bound over the - existing local bounded-turn snapshot; a second limited SQL/query surface is not - justified at the stated scale. -- Round 2 `KISS`: five findings applied and one defended. Exact wait correlation moved - into a separate, narrow projection so existing pending-turn ingestion and pagination - remain unchanged; internal correlation events stay out of public streams; orphan - timing and dispatch sequence were removed. Final broad validation remains because - the user explicitly requested the implement-plan and guarded LastCode delivery - workflow. -- Round 3 `basic`: four findings applied and two boundaries defended. Correlation rows - are now feature-era/CLI-only, deletion cleans them up, the internal resolution path - tolerates a deleted thread, and wait handles correlation-before-turn-projection. The - plan explicitly accepts timeout across the existing provider-acceptance/persistence - crash window instead of importing V2 recovery machinery, and records the user's - authorization for final repo-wide delivery validation. -- Round 3 `best-practices`: four findings applied. The tracking marker now has an exact - typed command/normalizer/event path, one stable request-derived resolution command ID - makes outcome persistence one-shot, volatile timestamps do not affect idempotency, - and deleted-thread versus missing-correlation wait errors are distinct. -- Round 3 `KISS`: four findings applied. Current identity and inspection are one - read-only slice, correlation-marker plumbing moves entirely into the wait slice, only - marked requests enter the reactor finalizer, and late resolution updates existing - rows only so deleted state cannot reappear. -- Round 4 `basic`: two findings applied. Plain send and timed-out wait handles now have - distinct schemas, only `send --wait` creates correlation, standalone `wait` only - resumes a timed-out handle, and the slice-specific encoding tests match that split. -- Round 4 `best-practices`: two findings applied. `send --wait` drops operate privilege - before opening a read-only wait session, and resumable handles have their own nested - `kind: wait-handle` schema so a plain accepted result cannot be mistaken for one. -- Round 4 `KISS`: two findings applied. Standalone wait receives one shell-quoted - compact-JSON handle with strict decoding, and the terminal outcome union now states - exactly when turn identity and assistant text are present. -- Round 5 `basic`: one finding applied. Server timeout normally precedes the CLI - transport deadline, while transport failure still returns the already-known handle - and revokes the read-only credential so the wait remains resumable. -- Round 5 `best-practices`: two findings applied. Ambiguous dispatch retries once with - stable IDs and otherwise returns `dispatch-unknown` without claiming acceptance; - confirmed dispatch emits its handle before blocking so interruption preserves a way - to resume while credentials are still cleaned up. -- Round 5 `KISS`: three findings applied. Candidate handles from dispatch, transport, - and timeout outcomes are all valid standalone-wait inputs; connection uncertainty is - `transport-unknown`, not `timed-out`; and interruption recovery uses one precisely - framed stderr line while stdout remains one final JSON object. -- Round 6 `basic`: one finding applied. Focused CLI coverage now forces transport - uncertainty and verifies its handle, credential cleanup, and exact stderr/stdout - framing; the recovery record is explicitly machine-readable rather than shell code. -- Round 6 `best-practices` and `KISS`: clean. No material findings remained. -- Round 7 `basic`: clean. Basic, best-practices, and KISS were all quiet; the plan is - ready for implementation. - -## Implementation Results - -Pending. From 9f9c2cf7bfc5bf5e5832b81f33237fb8d4b2ae14 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 02:58:21 -0700 Subject: [PATCH 07/23] fix(lastcode): finish empty tracked replies --- apps/server/src/bin.test.ts | 65 +++++++++++++++++++ .../Layers/TurnRequestWaitQuery.ts | 14 ++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 8889ed0a885c..755486da48fc 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1020,6 +1020,71 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { response: `${spilledResponse}${responseTail}`, responseTruncated: false, }); + const emptyMessageId = MessageId.make("message-completed-without-assistant"); + const emptyTurnId = TurnId.make("turn-completed-without-assistant"); + const emptyAt = DateTime.formatIso(yield* DateTime.now); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-empty-response-start"), + threadId, + message: { + messageId: emptyMessageId, + role: "user", + text: "Complete without an assistant message.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:empty-response"), + threadId, + messageId: emptyMessageId, + outcome: { kind: "started", turnId: emptyTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-empty-response-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: emptyTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-empty-response-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: emptyMessageId }), + { + kind: "terminal", + state: "completed", + turnId: emptyTurnId, + response: "", + }, + ); const trackedTurnId = TurnId.make("turn-live-wait"); const trackedMessageId = MessageId.make("message-live-wait-request"); const createdAt = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index dbc510782f7e..815d7bc53eb2 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -54,11 +54,15 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { } if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { if (value.turnState === "completed") { - if ( - value.assistantMessageId === null || - value.response === null || - value.responseStreaming !== 0 - ) { + if (value.assistantMessageId === null) { + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: "", + } as const; + } + if (value.response === null || value.responseStreaming !== 0) { return { kind: "pending" } as const; } return { From bb369c4df36a49e8c29f3d2bb69052b2cbb8ea86 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 03:18:10 -0700 Subject: [PATCH 08/23] fix(lastcode): wait for assistant finalization --- apps/server/src/bin.test.ts | 103 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 9 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 14 +++ .../Layers/ProviderRuntimeIngestion.ts | 8 ++ .../Layers/TurnRequestWaitQuery.ts | 10 +- apps/server/src/orchestration/decider.ts | 17 +++ .../decider.turnRequestCorrelation.test.ts | 29 +++++ apps/server/src/orchestration/http.ts | 1 + .../ProjectionTurnRequestCorrelations.ts | 30 ++++- ..._ProjectionTurnRequestCorrelations.test.ts | 28 +++++ .../045_ProjectionTurnRequestCorrelations.ts | 8 ++ .../ProjectionTurnRequestCorrelations.ts | 5 + packages/contracts/src/orchestration.ts | 21 ++++ 13 files changed, 277 insertions(+), 6 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 755486da48fc..b0e9b6b6c2e2 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1076,6 +1076,17 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }, createdAt: emptyAt, }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: emptyMessageId }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-empty-response-assistant-finalized"), + threadId, + turnId: emptyTurnId, + createdAt: emptyAt, + }); assert.deepStrictEqual( yield* engine.getTurnRequestWaitState({ threadId, messageId: emptyMessageId }), { @@ -1085,6 +1096,98 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { response: "", }, ); + const bufferedMessageId = MessageId.make("message-short-buffered-request"); + const bufferedTurnId = TurnId.make("turn-short-buffered-response"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-short-buffered-start"), + threadId, + message: { + messageId: bufferedMessageId, + role: "user", + text: "Return a short buffered answer.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:short-buffered"), + threadId, + messageId: bufferedMessageId, + outcome: { kind: "started", turnId: bufferedTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-short-buffered-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: bufferedTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-short-buffered-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: bufferedMessageId }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-short-buffered-delta"), + threadId, + messageId: MessageId.make("message-short-buffered-answer"), + delta: "short complete answer", + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-short-buffered-message-complete"), + threadId, + messageId: MessageId.make("message-short-buffered-answer"), + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-short-buffered-assistant-finalized"), + threadId, + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: bufferedMessageId }), + { + kind: "terminal", + state: "completed", + turnId: bufferedTurnId, + response: "short complete answer", + }, + ); const trackedTurnId = TurnId.make("turn-live-wait"); const trackedMessageId = MessageId.make("message-live-wait-request"); const createdAt = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6fb92a72fef3..90b22f7be49d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1209,6 +1209,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.turn-assistant-finalized": { + yield* projectionTurnRequestCorrelationRepository.markAssistantFinalized({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + finalizedAt: event.payload.finalizedAt, + }); + return; + } + case "thread.deleted": { yield* projectionTurnRequestCorrelationRepository.deleteByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4a3a250bb256..d1c9a574d5a0 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2637,6 +2637,7 @@ describe("ProviderRuntimeIngestion", () => { message.id === "assistant:item-complete-dedup" && !message.streaming, ), ); + await harness.drain(); const events = await Effect.runPromise( Stream.runCollect(harness.engine.readEvents(0)).pipe( @@ -2653,6 +2654,19 @@ describe("ProviderRuntimeIngestion", () => { ); }); expect(completionEvents).toHaveLength(1); + const completionIndex = events.findIndex( + (event) => + event.type === "thread.message-sent" && + event.payload.messageId === "assistant:item-complete-dedup" && + event.payload.streaming === false, + ); + const finalizedIndex = events.findIndex( + (event) => + event.type === "thread.turn-assistant-finalized" && + event.payload.turnId === "turn-complete-dedup", + ); + expect(completionIndex).toBeGreaterThanOrEqual(0); + expect(finalizedIndex).toBeGreaterThan(completionIndex); }); it("maps canonical request events into approval activities with requestKind", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d0f70f7edf1d..d243a7ef2fbf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1873,6 +1873,14 @@ const make = Effect.gen(function* () { turnId, updatedAt: now, }); + + yield* orchestrationEngine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: yield* providerCommandId(event, "turn-assistant-finalize"), + threadId: thread.id, + turnId, + createdAt: now, + }); } } diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 815d7bc53eb2..2482eb3063f1 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -19,6 +19,7 @@ const WaitRow = Schema.Struct({ assistantMessageId: Schema.NullOr(MessageId), response: Schema.NullOr(Schema.String), responseStreaming: Schema.NullOr(Schema.Number), + assistantFinalizedAt: Schema.NullOr(Schema.String), }); export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { @@ -28,12 +29,16 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { execute: ({ threadId, messageId }) => sql` SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", - messages.text AS "response", messages.is_streaming AS "responseStreaming" + messages.text AS "response", messages.is_streaming AS "responseStreaming", + finalizations.finalized_at AS "assistantFinalizedAt" FROM projection_turn_request_correlations AS correlations LEFT JOIN projection_turns AS turns ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id LEFT JOIN projection_thread_messages AS messages ON messages.message_id = turns.assistant_message_id + LEFT JOIN projection_turn_assistant_finalizations AS finalizations + ON finalizations.thread_id = correlations.thread_id + AND finalizations.turn_id = correlations.turn_id WHERE correlations.thread_id = ${threadId} AND correlations.message_id = ${messageId} LIMIT 1 `, @@ -55,6 +60,9 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { if (value.turnState === "completed") { if (value.assistantMessageId === null) { + if (value.assistantFinalizedAt === null) { + return { kind: "pending" } as const; + } return { kind: "terminal", state: "completed", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b3e20554aaad..96016f702421 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1323,6 +1323,23 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn-assistant.finalize": { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-assistant-finalized", + payload: { + threadId: command.threadId, + turnId: command.turnId, + finalizedAt: command.createdAt, + }, + }; + } + case "thread.session.set": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts index 06d8a4d02467..52c93a36eac1 100644 --- a/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts +++ b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts @@ -42,3 +42,32 @@ it.effect("resolves tracked requests without requiring the thread to still exist } }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("records assistant finalization without requiring the thread to still exist", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("turn-assistant-finalize:event-1"), + threadId: ThreadId.make("deleted-thread"), + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-22T00:00:00.000Z", + }, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }); + + const isFinalized = "type" in event && event.type === "thread.turn-assistant-finalized"; + assert.strictEqual(isFinalized, true); + if (isFinalized) { + const finalized = event as Extract< + OrchestrationEvent, + { readonly type: "thread.turn-assistant-finalized" } + >; + assert.deepStrictEqual(finalized.payload, { + threadId: "deleted-thread", + turnId: "turn-1", + finalizedAt: "2026-08-22T00:00:00.000Z", + }); + } + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 192b920ad7e6..cbec2086893f 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -129,6 +129,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( event.aggregateKind === "thread" && event.aggregateId === handle.threadId && (event.type === "thread.turn-request-resolved" || + event.type === "thread.turn-assistant-finalized" || event.type === "thread.turn-interrupt-requested" || event.type === "thread.session-set" || event.type === "thread.message-sent" || diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts index 3b9d77fccf1a..1be528036dc5 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts @@ -44,15 +44,35 @@ const make = Effect.gen(function* () { const get: ProjectionTurnRequestCorrelationRepositoryShape["get"] = (input) => getRow(input).pipe(Effect.mapError(toPersistenceSqlError("turnCorrelation.get"))); + const markAssistantFinalized: ProjectionTurnRequestCorrelationRepositoryShape["markAssistantFinalized"] = + (input) => + sql` + INSERT INTO projection_turn_assistant_finalizations (thread_id, turn_id, finalized_at) + VALUES (${input.threadId}, ${input.turnId}, ${input.finalizedAt}) + ON CONFLICT (thread_id, turn_id) DO NOTHING + `.pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("turnCorrelation.markAssistantFinalized")), + ); + const deleteByThreadId: ProjectionTurnRequestCorrelationRepositoryShape["deleteByThreadId"] = ({ threadId, }) => - sql`DELETE FROM projection_turn_request_correlations WHERE thread_id = ${threadId}`.pipe( - Effect.asVoid, - Effect.mapError(toPersistenceSqlError("turnCorrelation.deleteByThreadId")), - ); + sql + .withTransaction( + sql`DELETE FROM projection_turn_request_correlations WHERE thread_id = ${threadId}`.pipe( + Effect.flatMap( + () => + sql`DELETE FROM projection_turn_assistant_finalizations WHERE thread_id = ${threadId}`, + ), + ), + ) + .pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("turnCorrelation.deleteByThreadId")), + ); - return { insertPending, resolve, get, deleteByThreadId }; + return { insertPending, resolve, markAssistantFinalized, get, deleteByThreadId }; }); export const ProjectionTurnRequestCorrelationRepositoryLive = Layer.effect( diff --git a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts index 69245dd14d4e..b0cf8ce241dd 100644 --- a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts +++ b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.test.ts @@ -2,6 +2,7 @@ import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProjectionTurnRequestCorrelationRepositoryLive } from "../Layers/ProjectionTurnRequestCorrelations.ts"; import { runMigrations } from "../Migrations.ts"; @@ -19,6 +20,7 @@ layer("045_ProjectionTurnRequestCorrelations", (it) => { Effect.gen(function* () { yield* runMigrations(); const repository = yield* ProjectionTurnRequestCorrelationRepository; + const sql = yield* SqlClient.SqlClient; const key = { threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1") }; yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:00.000Z" }); yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:01.000Z" }); @@ -34,6 +36,16 @@ layer("045_ProjectionTurnRequestCorrelations", (it) => { state: "error", resolvedAt: "2026-08-22T00:00:03.000Z", }); + yield* repository.markAssistantFinalized({ + threadId: key.threadId, + turnId: TurnId.make("turn-1"), + finalizedAt: "2026-08-22T00:00:04.000Z", + }); + yield* repository.markAssistantFinalized({ + threadId: key.threadId, + turnId: TurnId.make("turn-1"), + finalizedAt: "2026-08-22T00:00:05.000Z", + }); const resolved = yield* repository.get(key); assert.strictEqual(resolved._tag, "Some"); if (resolved._tag === "Some") { @@ -41,8 +53,24 @@ layer("045_ProjectionTurnRequestCorrelations", (it) => { assert.strictEqual(resolved.value.turnId, "turn-1"); assert.strictEqual(resolved.value.requestedAt, "2026-08-22T00:00:00.000Z"); } + assert.deepStrictEqual( + yield* sql<{ readonly finalizedAt: string }>` + SELECT finalized_at AS "finalizedAt" + FROM projection_turn_assistant_finalizations + WHERE thread_id = ${key.threadId} AND turn_id = 'turn-1' + `, + [{ finalizedAt: "2026-08-22T00:00:04.000Z" }], + ); yield* repository.deleteByThreadId({ threadId: key.threadId }); assert.strictEqual((yield* repository.get(key))._tag, "None"); + assert.deepStrictEqual( + yield* sql` + SELECT 1 + FROM projection_turn_assistant_finalizations + WHERE thread_id = ${key.threadId} + `, + [], + ); }), ); }); diff --git a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts index ce3b61c81113..a84b97ded217 100644 --- a/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts +++ b/apps/server/src/persistence/Migrations/045_ProjectionTurnRequestCorrelations.ts @@ -14,4 +14,12 @@ export default Effect.gen(function* () { PRIMARY KEY (thread_id, message_id) ) `; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_turn_assistant_finalizations ( + thread_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + finalized_at TEXT NOT NULL, + PRIMARY KEY (thread_id, turn_id) + ) + `; }); diff --git a/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts index 1c4afb44bcc1..ca9fca6651a7 100644 --- a/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts +++ b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts @@ -26,6 +26,11 @@ export interface ProjectionTurnRequestCorrelationRepositoryShape { "threadId" | "messageId" | "turnId" | "state" | "resolvedAt" >, ) => Effect.Effect; + readonly markAssistantFinalized: (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly finalizedAt: IsoDateTime; + }) => Effect.Effect; readonly get: (input: { readonly threadId: ThreadId; readonly messageId: MessageId; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 525be4fadedc..0f478707db5f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1185,6 +1185,14 @@ const ThreadTurnRequestResolveCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnAssistantFinalizeCommand = Schema.Struct({ + type: Schema.Literal("thread.turn-assistant.finalize"), + commandId: CommandId, + threadId: ThreadId, + turnId: TurnId, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -1195,6 +1203,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, ThreadTurnRequestResolveCommand, + ThreadTurnAssistantFinalizeCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1228,6 +1237,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.message-sent", "thread.turn-start-requested", "thread.turn-request-resolved", + "thread.turn-assistant-finalized", "thread.turn-interrupt-requested", "thread.approval-response-requested", "thread.user-input-response-requested", @@ -1422,6 +1432,12 @@ export const ThreadTurnRequestResolvedPayload = Schema.Struct({ outcome: ThreadTurnRequestOutcome, }); +export const ThreadTurnAssistantFinalizedPayload = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + finalizedAt: IsoDateTime, +}); + export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, turnId: Schema.optional(TurnId), @@ -1634,6 +1650,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.turn-request-resolved"), payload: ThreadTurnRequestResolvedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-assistant-finalized"), + payload: ThreadTurnAssistantFinalizedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-interrupt-requested"), From d573e9609f2b6217051e7ce22e761e433fdd432f Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 03:27:42 -0700 Subject: [PATCH 09/23] test(server): drain correlation reactor deterministically --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5546d41b7528..bea66f0fed6b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -707,8 +707,9 @@ describe("ProviderCommandReactor", () => { createdAt: "2026-01-01T00:00:00.000Z", }), ); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await waitFor(() => harness.turnRequestResolutionDispatchAttempts === 1); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.turnRequestResolutionDispatchAttempts).toBe(1); const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); expect(thread?.session?.status).not.toBe("error"); }); From 751be2329f66f94eccad66c3355ec1f2655a22e2 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 03:38:30 -0700 Subject: [PATCH 10/23] fix(lastcode): await complete assistant reply --- apps/server/src/bin.test.ts | 31 +++++++++++++++++++ .../Layers/TurnRequestWaitQuery.ts | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index b0e9b6b6c2e2..b01329e5516b 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -969,6 +969,13 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { turnId: composedTurnId, createdAt: responseAt, }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-composed-wait-assistant-finalized"), + threadId, + turnId: composedTurnId, + createdAt: responseAt, + }); return event.payload.messageId; }); }, @@ -1136,6 +1143,23 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }, createdAt: emptyAt, }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-short-buffered-commentary-delta"), + threadId, + messageId: MessageId.make("message-short-buffered-commentary"), + delta: "Earlier commentary segment", + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-short-buffered-commentary-complete"), + threadId, + messageId: MessageId.make("message-short-buffered-commentary"), + turnId: bufferedTurnId, + createdAt: emptyAt, + }); yield* engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-short-buffered-complete"), @@ -1261,6 +1285,13 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }, createdAt, }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-live-wait-assistant-finalized"), + threadId, + turnId: trackedTurnId, + createdAt, + }); const recoveryHandle = { kind: "wait-handle" as const, environmentId: "env-thread-live", diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 2482eb3063f1..2d15e79a34c9 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -59,10 +59,10 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { } if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { if (value.turnState === "completed") { + if (value.assistantFinalizedAt === null) { + return { kind: "pending" } as const; + } if (value.assistantMessageId === null) { - if (value.assistantFinalizedAt === null) { - return { kind: "pending" } as const; - } return { kind: "terminal", state: "completed", From 8aec7098111e1652364da24ecb202a2fb90e856e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 03:48:56 -0700 Subject: [PATCH 11/23] fix(lastcode): interrupt superseded tracked turns --- .../Layers/ProjectionPipeline.test.ts | 48 ++++++++++++++++++- .../Layers/ProjectionPipeline.ts | 2 +- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 45525edbb489..0f7c7ad38a77 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -33,6 +33,7 @@ import { OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { makeTurnRequestWaitQuery } from "./TurnRequestWaitQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -1519,6 +1520,43 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + const trackedMessageId = MessageId.make("message-turn-superseded"); + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make("evt-ts-requested"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ts-requested"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ts-requested"), + metadata: {}, + payload: { + threadId, + messageId: trackedMessageId, + runtimeMode: "full-access", + interactionMode: "default", + trackRequestCorrelation: true, + createdAt: now, + }, + }); + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-ts-resolved"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ts-resolved"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ts-resolved"), + metadata: {}, + payload: { + threadId, + messageId: trackedMessageId, + outcome: { kind: "started", turnId: oldTurnId }, + }, + }); + const appendRunningSessionSet = (eventId: string, turnId: TurnId, updatedAt: string) => eventStore.append({ type: "thread.session-set", @@ -1562,9 +1600,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ORDER BY requested_at `; assert.deepEqual(rows, [ - { turnId: oldTurnId, state: "completed", completedAt: "2026-01-01T00:00:30.000Z" }, + { turnId: oldTurnId, state: "interrupted", completedAt: "2026-01-01T00:00:30.000Z" }, { turnId: newTurnId, state: "running", completedAt: null }, ]); + assert.deepEqual( + yield* makeTurnRequestWaitQuery(sql).getState({ threadId, messageId: trackedMessageId }), + { + kind: "terminal", + state: "interrupted", + turnId: oldTurnId, + }, + ); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 90b22f7be49d..5575bb40c20f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1282,7 +1282,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : projectionTurnRepository.upsertByTurnId({ ...turn, turnId: turn.turnId, - state: "completed", + state: "interrupted", completedAt: event.payload.session.updatedAt, }), { concurrency: 1 }, From df77a8dda6648d804bdb346c5e8e8e0908e95f3b Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 04:00:13 -0700 Subject: [PATCH 12/23] test(server): drain steering ingestion deterministically --- .../Layers/ProviderRuntimeIngestion.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d1c9a574d5a0..b4aaefbf9745 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1632,13 +1632,12 @@ describe("ProviderRuntimeIngestion", () => { threadId, turnId: oldTurnId, }); - await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === oldTurnId, - 2_000, - threadId, + await harness.drain(); + const threadBeforeSteer = (await harness.readModel()).threads.find( + (thread) => thread.id === threadId, ); + expect(threadBeforeSteer?.session?.status).toBe("running"); + expect(threadBeforeSteer?.session?.activeTurnId).toBe(oldTurnId); // The steer: a user-requested turn start while the old turn still runs. await Effect.runPromise( @@ -1678,16 +1677,13 @@ describe("ProviderRuntimeIngestion", () => { turnId: newTurnId, }); - const threadAfterSteer = await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === newTurnId, - 2_000, - threadId, + await harness.drain(); + const threadAfterSteer = (await harness.readModel()).threads.find( + (thread) => thread.id === threadId, ); - expect(threadAfterSteer.session?.activeTurnId).toBe(newTurnId); - expect(threadAfterSteer.latestTurn?.turnId).toBe(newTurnId); - expect(threadAfterSteer.latestTurn?.state).toBe("running"); + expect(threadAfterSteer?.session?.activeTurnId).toBe(newTurnId); + expect(threadAfterSteer?.latestTurn?.turnId).toBe(newTurnId); + expect(threadAfterSteer?.latestTurn?.state).toBe("running"); }); it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { From 06eb1e9b8c74b712f4541f2259679f5421a3ad49 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 04:12:23 -0700 Subject: [PATCH 13/23] test(server): drain native identity ingestion --- .../Layers/ProviderRuntimeIngestion.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b4aaefbf9745..418ea8889f71 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -753,10 +753,12 @@ describe("ProviderRuntimeIngestion", () => { payload: { providerThreadId: "codex-native-lifecycle" }, createdAt: "2026-01-01T00:00:01.000Z", }); - await waitForThread( - harness.readShell as never, - (thread) => thread.session?.providerThreadId === "codex-native-lifecycle", + await harness.drain(); + const shellAfterThreadStarted = await harness.readShell(); + const threadAfterThreadStarted = shellAfterThreadStarted.threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), ); + expect(threadAfterThreadStarted?.session?.providerThreadId).toBe("codex-native-lifecycle"); harness.emit({ type: "turn.started", eventId: asEventId("evt-native-turn-started"), @@ -765,12 +767,13 @@ describe("ProviderRuntimeIngestion", () => { turnId: asTurnId("turn-native-lifecycle"), createdAt: "2026-01-01T00:00:02.000Z", }); - await waitForThread( - harness.readShell as never, - (thread) => - thread.session?.status === "running" && - thread.session.providerThreadId === "codex-native-lifecycle", + await harness.drain(); + const shellAfterTurnStarted = await harness.readShell(); + const threadAfterTurnStarted = shellAfterTurnStarted.threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), ); + expect(threadAfterTurnStarted?.session?.status).toBe("running"); + expect(threadAfterTurnStarted?.session?.providerThreadId).toBe("codex-native-lifecycle"); }); it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { From d64b6331f7139d4a6026272be4842760889b507d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 04:29:27 -0700 Subject: [PATCH 14/23] fix(server): preserve interrupted provider turns --- .../Layers/ProviderRuntimeIngestion.test.ts | 138 ++++++++++++++---- .../Layers/ProviderRuntimeIngestion.ts | 10 +- 2 files changed, 119 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 418ea8889f71..cd25503157fe 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -263,6 +263,12 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); + const readEvents = () => + Effect.runPromise( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const readTurnRequestWaitState = (threadId: ThreadId, messageId: MessageId) => + Effect.runPromise(engine.getTurnRequestWaitState({ threadId, messageId })); const createdAt = "2026-01-01T00:00:00.000Z"; await dispatch({ @@ -320,6 +326,8 @@ describe("ProviderRuntimeIngestion", () => { return { engine, dispatch, + readEvents, + readTurnRequestWaitState, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), readShell: () => Effect.runPromise(snapshotQuery.getShellSnapshot()), emit: provider.emit, @@ -776,6 +784,94 @@ describe("ProviderRuntimeIngestion", () => { expect(threadAfterTurnStarted?.session?.providerThreadId).toBe("codex-native-lifecycle"); }); + it("projects Codex interruption and Cursor cancellation as interrupted tracked waits", async () => { + const harness = await createHarness(); + const createdAt = "2026-01-01T00:00:00.000Z"; + const cases = [ + { + provider: ProviderDriverKind.make("codex"), + state: "interrupted" as const, + suffix: "codex", + }, + { + provider: ProviderDriverKind.make("cursor"), + state: "cancelled" as const, + suffix: "cursor", + }, + ]; + + for (const [index, entry] of cases.entries()) { + const threadId = ThreadId.make(`thread-${index + 1}`); + const turnId = asTurnId(`turn-interrupted-${entry.suffix}`); + const messageId = asMessageId(`message-interrupted-${entry.suffix}`); + if (index > 0) { + await harness.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-thread-create-${entry.suffix}`), + threadId, + projectId: asProjectId("project-1"), + title: `Interrupted ${entry.suffix}`, + modelSelection: { + instanceId: ProviderInstanceId.make(entry.provider), + model: "test-model", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + } + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-${entry.suffix}`), + threadId, + message: { + messageId, + role: "user", + text: "Track this interrupted turn.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt, + }); + await harness.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`cmd-turn-resolve-${entry.suffix}`), + threadId, + messageId, + outcome: { kind: "started", turnId }, + createdAt, + }); + harness.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-started-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + createdAt, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-completed-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + payload: { state: entry.state }, + createdAt, + }); + await harness.drain(); + + expect(await harness.readTurnRequestWaitState(threadId, messageId)).toEqual({ + kind: "terminal", + state: "interrupted", + turnId, + }); + } + }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); const seededAt = "2026-01-01T00:00:00.000Z"; @@ -2281,11 +2377,7 @@ describe("ProviderRuntimeIngestion", () => { expect(resumedMessage?.text).toBe(" second half"); expect(resumedMessage?.streaming).toBe(false); - const events = await Effect.runPromise( - Stream.runCollect(harness.engine.readEvents(0)).pipe( - Effect.map((chunk) => Array.from(chunk)), - ), - ); + const events = await harness.readEvents(); const assistantEvents = events.filter( (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> => event.type === "thread.message-sent" && @@ -2419,22 +2511,20 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-streaming-mode"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("message-streaming-mode"), - role: "user", - text: "stream please", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-streaming-mode"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-streaming-mode"), + role: "user", + text: "stream please", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); await harness.drain(); harness.emit({ @@ -2638,11 +2728,7 @@ describe("ProviderRuntimeIngestion", () => { ); await harness.drain(); - const events = await Effect.runPromise( - Stream.runCollect(harness.engine.readEvents(0)).pipe( - Effect.map((chunk) => Array.from(chunk)), - ), - ); + const events = await harness.readEvents(); const completionEvents = events.filter((event) => { if (event.type !== "thread.message-sent") { return false; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d243a7ef2fbf..d8c6bbc9b7fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1582,10 +1582,14 @@ const make = Effect.gen(function* () { return "running"; case "session.exited": return "stopped"; - case "turn.completed": - return normalizeRuntimeTurnState(event.payload.state) === "failed" + case "turn.completed": { + const turnState = normalizeRuntimeTurnState(event.payload.state); + return turnState === "failed" ? "error" - : "ready"; + : turnState === "interrupted" || turnState === "cancelled" + ? "interrupted" + : "ready"; + } case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an From d8980c8df57aeccd7b2a2f7bc7e5beb3db7e477b Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 04:45:38 -0700 Subject: [PATCH 15/23] fix(server): finish tool-only tracked waits --- apps/server/src/bin.test.ts | 98 +++++++++++++++++++ .../Layers/TurnRequestWaitQuery.ts | 13 ++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index b01329e5516b..d8137a6700e5 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -8,6 +8,7 @@ import * as NodeSqlite from "node:sqlite"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CheckpointRef, CommandId, EnvironmentId, EnvironmentMetadataHttpApi, @@ -1103,6 +1104,103 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { response: "", }, ); + const checkpointMessageId = MessageId.make("message-checkpoint-only-request"); + const checkpointTurnId = TurnId.make("turn-checkpoint-only-response"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-checkpoint-only-start"), + threadId, + message: { + messageId: checkpointMessageId, + role: "user", + text: "Complete with tools only.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:checkpoint-only"), + threadId, + messageId: checkpointMessageId, + outcome: { kind: "started", turnId: checkpointTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-checkpoint-only-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: checkpointTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-checkpoint-only-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + const syntheticAssistantMessageId = MessageId.make(`assistant:${checkpointTurnId}`); + yield* engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-checkpoint-only-diff-complete"), + threadId, + turnId: checkpointTurnId, + completedAt: emptyAt, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/checkpoint-only"), + status: "ready", + files: [], + assistantMessageId: syntheticAssistantMessageId, + checkpointTurnCount: 1, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-checkpoint-only-assistant-finalized"), + threadId, + turnId: checkpointTurnId, + createdAt: emptyAt, + }); + const checkpointDetail = yield* query.getThreadDetailSnapshot(threadId); + assert.isTrue(Option.isSome(checkpointDetail)); + if (Option.isSome(checkpointDetail)) { + assert.isFalse( + checkpointDetail.value.thread.messages.some( + (message) => message.id === syntheticAssistantMessageId, + ), + ); + } + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ + threadId, + messageId: checkpointMessageId, + }), + { + kind: "terminal", + state: "completed", + turnId: checkpointTurnId, + response: "", + }, + ); const bufferedMessageId = MessageId.make("message-short-buffered-request"); const bufferedTurnId = TurnId.make("turn-short-buffered-response"); yield* engine.dispatch({ diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 2d15e79a34c9..224ac3244f72 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -70,7 +70,18 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { response: "", } as const; } - if (value.response === null || value.responseStreaming !== 0) { + if (value.response === null) { + if (value.assistantMessageId !== MessageId.make(`assistant:${value.turnId}`)) { + return { kind: "pending" } as const; + } + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: "", + } as const; + } + if (value.responseStreaming !== 0) { return { kind: "pending" } as const; } return { From d197f2fb5531bcdcb43dabbc87be8a39e4706a11 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sat, 22 Aug 2026 05:00:29 -0700 Subject: [PATCH 16/23] perf(server): ignore token wait wakeups --- apps/server/src/orchestration/http.test.ts | 96 ++++++++++++++++++++++ apps/server/src/orchestration/http.ts | 82 +++++++++--------- 2 files changed, 141 insertions(+), 37 deletions(-) create mode 100644 apps/server/src/orchestration/http.test.ts diff --git a/apps/server/src/orchestration/http.test.ts b/apps/server/src/orchestration/http.test.ts new file mode 100644 index 000000000000..d7e15bb901a6 --- /dev/null +++ b/apps/server/src/orchestration/http.test.ts @@ -0,0 +1,96 @@ +import { + CommandId, + CorrelationId, + EventId, + MessageId, + type OrchestrationEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { readThreadWaitUntilTerminal } from "./http.ts"; + +it.effect("ignores message deltas until a terminal wait event arrives", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-wait-wake-filter"); + const turnId = TurnId.make("turn-wait-wake-filter"); + const occurredAt = "2026-01-01T00:00:00.000Z"; + const events: ReadonlyArray = [ + { + sequence: 1, + type: "thread.message-sent", + eventId: EventId.make("evt-wait-token-delta"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make("cmd-wait-token-delta"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-wait-token-delta"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("assistant:wait-token-delta"), + role: "assistant", + text: "token", + turnId, + streaming: true, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }, + { + sequence: 2, + type: "thread.turn-assistant-finalized", + eventId: EventId.make("evt-wait-assistant-finalized"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make("cmd-wait-assistant-finalized"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-wait-assistant-finalized"), + metadata: {}, + payload: { threadId, turnId, finalizedAt: occurredAt }, + }, + ]; + const finalized = yield* Ref.make(false); + const reads = yield* Ref.make(0); + const eventQueue = yield* Queue.unbounded(); + yield* Queue.offerAll(eventQueue, events); + const eventStream = Stream.fromQueue(eventQueue).pipe( + Stream.tap((event) => + event.type === "thread.turn-assistant-finalized" ? Ref.set(finalized, true) : Effect.void, + ), + ); + const readState = Effect.gen(function* () { + yield* Ref.update(reads, (count) => count + 1); + return (yield* Ref.get(finalized)) + ? ({ + kind: "terminal", + state: "completed", + turnId, + response: "complete", + } as const) + : ({ kind: "pending" } as const); + }); + + const result = yield* readThreadWaitUntilTerminal( + threadId, + { kind: "pending" }, + eventStream, + readState, + ); + + assert.deepEqual(result, { + kind: "terminal", + state: "completed", + turnId, + response: "complete", + }); + assert.equal(yield* Ref.get(reads), 1); + }), +); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index cbec2086893f..41f137352bf6 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -2,6 +2,8 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type OrchestrationEvent, + type ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -25,6 +27,36 @@ import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; const THREAD_WAIT_RESPONSE_MAX_CHARS = 64_000; +export const readThreadWaitUntilTerminal = ( + threadId: ThreadId, + latest: TurnRequestWaitState, + events: Stream.Stream, + readState: Effect.Effect, +): Effect.Effect => { + const changes = events.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === threadId && + (event.type === "thread.turn-request-resolved" || + event.type === "thread.turn-assistant-finalized" || + event.type === "thread.turn-interrupt-requested" || + event.type === "thread.session-set" || + event.type === "thread.deleted"), + ), + ); + const readUntilTerminal = ( + state: TurnRequestWaitState, + ): Effect.Effect => + state.kind === "pending" + ? changes.pipe( + Stream.runHead, + Effect.flatMap(() => readState.pipe(Effect.flatMap(readUntilTerminal))), + ) + : Effect.succeed(state); + return readUntilTerminal(latest); +}; + export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "orchestration", @@ -123,44 +155,20 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( return yield* failEnvironmentInvalidRequest("wrong_environment"); } - const relevantEvents = (yield* orchestrationEngine.subscribeDomainEvents).pipe( - Stream.filter( - (event) => - event.aggregateKind === "thread" && - event.aggregateId === handle.threadId && - (event.type === "thread.turn-request-resolved" || - event.type === "thread.turn-assistant-finalized" || - event.type === "thread.turn-interrupt-requested" || - event.type === "thread.session-set" || - event.type === "thread.message-sent" || - event.type === "thread.deleted"), - ), - ); - const subscription = { - latest: yield* orchestrationEngine - .getTurnRequestWaitState(handle) - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), - ), + const events = yield* orchestrationEngine.subscribeDomainEvents; + const latest = yield* orchestrationEngine + .getTurnRequestWaitState(handle) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), ), - changes: relevantEvents, - }; - - const readUntilTerminal = ( - state: TurnRequestWaitState, - ): Effect.Effect => - state.kind === "pending" - ? subscription.changes.pipe( - Stream.runHead, - Effect.flatMap(() => - orchestrationEngine - .getTurnRequestWaitState(handle) - .pipe(Effect.flatMap(readUntilTerminal)), - ), - ) - : Effect.succeed(state); - const waited = yield* readUntilTerminal(subscription.latest).pipe( + ); + const waited = yield* readThreadWaitUntilTerminal( + handle.threadId, + latest, + events, + orchestrationEngine.getTurnRequestWaitState(handle), + ).pipe( Effect.timeoutOption(`${args.payload.timeoutMs} millis`), Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), From 1c80e6a45a8f7dc65dd3ba921867668ebb737450 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 23 Aug 2026 17:13:12 -0700 Subject: [PATCH 17/23] fix(lastcode): preserve commands in thread tool path --- .../src/provider/Layers/CodexAdapter.test.ts | 36 +++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 871673faab85..542ee95b0f2b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -510,6 +510,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps the default POSIX command path when the parent path is empty", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: "" }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-thread-path-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-default-command-path"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal( + runtime.options.environment?.PATH, + `${NodePath.join(config.stateDir, "bin")}:/usr/bin:/bin`, + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("injects LastCode identity without a POSIX wrapper on Windows", () => { const runtimeFactory = makeRuntimeFactory(); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e8a9f1e65b76..d6b73e09ff3a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1696,7 +1696,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const codexEnvironment = { ...runtimeEnvironment, - ...(threadTool ? { PATH: `${threadTool.binDir}:${runtimeEnvironment.PATH ?? ""}` } : {}), + ...(threadTool + ? { PATH: `${threadTool.binDir}:${runtimeEnvironment.PATH || "/usr/bin:/bin"}` } + : {}), T3CODE_THREAD_ID: input.threadId, T3CODE_HOME: serverConfig.baseDir, }; From a2c2002d64aaecc53c0808aae6da290faf497a30 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 23 Aug 2026 17:34:43 -0700 Subject: [PATCH 18/23] fix(lastcode): wait for provider interruption --- .../Layers/TurnRequestWaitQuery.test.ts | 35 +++++++ .../Layers/TurnRequestWaitQuery.ts | 95 ++++++++++--------- apps/server/src/orchestration/http.test.ts | 13 +++ apps/server/src/orchestration/http.ts | 1 - 4 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts new file mode 100644 index 000000000000..1f547b82dd84 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts @@ -0,0 +1,35 @@ +import { TurnId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { resolveTurnRequestWaitState } from "./TurnRequestWaitQuery.ts"; + +const interruptedRow = { + correlationState: "started" as const, + turnId: TurnId.make("turn-interrupt-wait"), + turnState: "interrupted" as const, + assistantMessageId: null, + response: null, + responseStreaming: null, + assistantFinalizedAt: null, + sessionStatus: "running" as const, + sessionActiveTurnId: TurnId.make("turn-interrupt-wait"), +}; + +it("keeps waiting while an interrupt request has not stopped the active provider turn", () => { + assert.deepEqual(resolveTurnRequestWaitState(interruptedRow), { kind: "pending" }); +}); + +it("settles after the provider session confirms interruption", () => { + assert.deepEqual( + resolveTurnRequestWaitState({ + ...interruptedRow, + sessionStatus: "interrupted", + sessionActiveTurnId: null, + }), + { + kind: "terminal", + state: "interrupted", + turnId: TurnId.make("turn-interrupt-wait"), + }, + ); +}); diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 224ac3244f72..5a04054a9731 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -20,8 +20,52 @@ const WaitRow = Schema.Struct({ response: Schema.NullOr(Schema.String), responseStreaming: Schema.NullOr(Schema.Number), assistantFinalizedAt: Schema.NullOr(Schema.String), + sessionStatus: Schema.NullOr( + Schema.Literals(["idle", "starting", "running", "ready", "interrupted", "stopped", "error"]), + ), + sessionActiveTurnId: Schema.NullOr(TurnId), }); +export const resolveTurnRequestWaitState = (value: typeof WaitRow.Type): TurnRequestWaitState => { + if (value.correlationState === "error" || value.correlationState === "interrupted") { + return { kind: "terminal", state: value.correlationState }; + } + if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { + if ( + value.turnState === "interrupted" && + value.sessionStatus === "running" && + value.sessionActiveTurnId === value.turnId + ) { + return { kind: "pending" }; + } + if (value.turnState === "completed") { + if (value.assistantFinalizedAt === null) { + return { kind: "pending" }; + } + if (value.assistantMessageId === null) { + return { kind: "terminal", state: "completed", turnId: value.turnId, response: "" }; + } + if (value.response === null) { + if (value.assistantMessageId !== MessageId.make(`assistant:${value.turnId}`)) { + return { kind: "pending" }; + } + return { kind: "terminal", state: "completed", turnId: value.turnId, response: "" }; + } + if (value.responseStreaming !== 0) { + return { kind: "pending" }; + } + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: value.response, + }; + } + return { kind: "terminal", state: value.turnState, turnId: value.turnId }; + } + return { kind: "pending" }; +}; + export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { const getRow = SqlSchema.findOneOption({ Request: Schema.Struct({ threadId: ThreadId, messageId: MessageId }), @@ -30,7 +74,8 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", messages.text AS "response", messages.is_streaming AS "responseStreaming", - finalizations.finalized_at AS "assistantFinalizedAt" + finalizations.finalized_at AS "assistantFinalizedAt", + sessions.status AS "sessionStatus", sessions.active_turn_id AS "sessionActiveTurnId" FROM projection_turn_request_correlations AS correlations LEFT JOIN projection_turns AS turns ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id @@ -39,6 +84,8 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { LEFT JOIN projection_turn_assistant_finalizations AS finalizations ON finalizations.thread_id = correlations.thread_id AND finalizations.turn_id = correlations.turn_id + LEFT JOIN projection_thread_sessions AS sessions + ON sessions.thread_id = correlations.thread_id WHERE correlations.thread_id = ${threadId} AND correlations.message_id = ${messageId} LIMIT 1 `, @@ -53,51 +100,7 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { if (threads.length === 0) return { kind: "thread-not-found" } as const; const row = yield* getRow(input); if (Option.isNone(row)) return { kind: "correlation-not-found" } as const; - const value = row.value; - if (value.correlationState === "error" || value.correlationState === "interrupted") { - return { kind: "terminal", state: value.correlationState } as const; - } - if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { - if (value.turnState === "completed") { - if (value.assistantFinalizedAt === null) { - return { kind: "pending" } as const; - } - if (value.assistantMessageId === null) { - return { - kind: "terminal", - state: "completed", - turnId: value.turnId, - response: "", - } as const; - } - if (value.response === null) { - if (value.assistantMessageId !== MessageId.make(`assistant:${value.turnId}`)) { - return { kind: "pending" } as const; - } - return { - kind: "terminal", - state: "completed", - turnId: value.turnId, - response: "", - } as const; - } - if (value.responseStreaming !== 0) { - return { kind: "pending" } as const; - } - return { - kind: "terminal", - state: "completed", - turnId: value.turnId, - response: value.response, - } as const; - } - return { - kind: "terminal", - state: value.turnState, - turnId: value.turnId, - } as const; - } - return { kind: "pending" } as const; + return resolveTurnRequestWaitState(row.value); }).pipe( Effect.mapError((cause) => Schema.isSchemaError(cause) diff --git a/apps/server/src/orchestration/http.test.ts b/apps/server/src/orchestration/http.test.ts index d7e15bb901a6..93af04fc1a50 100644 --- a/apps/server/src/orchestration/http.test.ts +++ b/apps/server/src/orchestration/http.test.ts @@ -45,6 +45,19 @@ it.effect("ignores message deltas until a terminal wait event arrives", () => }, { sequence: 2, + type: "thread.turn-interrupt-requested", + eventId: EventId.make("evt-wait-interrupt-requested"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make("cmd-wait-interrupt-requested"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-wait-interrupt-requested"), + metadata: {}, + payload: { threadId, turnId, createdAt: occurredAt }, + }, + { + sequence: 3, type: "thread.turn-assistant-finalized", eventId: EventId.make("evt-wait-assistant-finalized"), aggregateKind: "thread", diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 41f137352bf6..fddf3c8fcb4a 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -40,7 +40,6 @@ export const readThreadWaitUntilTerminal = ( event.aggregateId === threadId && (event.type === "thread.turn-request-resolved" || event.type === "thread.turn-assistant-finalized" || - event.type === "thread.turn-interrupt-requested" || event.type === "thread.session-set" || event.type === "thread.deleted"), ), From 56dbc30b2956faa4e7c8ca15a5daa8be0bde7780 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 23 Aug 2026 23:15:32 -0700 Subject: [PATCH 19/23] fix(lastcode): finish interrupted thread waits --- apps/server/src/cli/thread.test.ts | 32 +++++++++++++++++ apps/server/src/cli/thread.ts | 15 ++++++++ .../Layers/ProviderRuntimeIngestion.test.ts | 35 ++++++++++++++----- .../Layers/ProviderRuntimeIngestion.ts | 12 +++++-- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 520a49e14532..a580cc075f0b 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -645,6 +645,38 @@ it.effect("marks only explicitly tracked sends for wait correlation", () => }), ); +it.effect("rejects waiting on the current thread before dispatch", () => + Effect.gen(function* () { + const { source } = runnerSource(); + let dispatchCount = 0; + const result = yield* Effect.result( + sendThreadOutput( + { + descriptor: source.descriptor, + shell: source.shell, + dispatch: () => { + dispatchCount += 1; + return Effect.void; + }, + }, + { + identifier: "thread-runner", + message: "pause for update", + commandId: CommandId.make("command-self-wait"), + messageId: MessageId.make("message-self-wait"), + createdAt: "2026-08-22T00:00:00.000Z", + trackRequestCorrelation: true, + rejectWaitForThreadId: ThreadId.make("thread-runner"), + }, + ), + ); + + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(result._tag === "Failure" ? result.failure._tag : "", "ThreadCliError"); + assert.strictEqual(dispatchCount, 0); + }), +); + it.effect("rejects blank, missing, ambiguous, and oversized sends before dispatch", () => Effect.gen(function* () { const { source } = runnerSource(); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 9a512e4886e9..60b66f093358 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -512,6 +512,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( readonly messageId: MessageId; readonly createdAt: string; readonly trackRequestCorrelation?: true; + readonly rejectWaitForThreadId?: ThreadId; }, ) { const resolution = resolveThreadTarget(source.shell.threads, input.identifier); @@ -530,6 +531,17 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( : {}), }); } + if ( + input.rejectWaitForThreadId !== undefined && + resolution.thread.id === input.rejectWaitForThreadId + ) { + return yield* new ThreadCliError({ + operation: "live send wait", + cause: new Error( + "Cannot use --wait when sending to the current thread because its queued turn cannot start until this command exits. Send without --wait instead.", + ), + }); + } const message = yield* decodeThreadSendMessage(input.message).pipe( Effect.mapError((cause) => new ThreadSendMessageError({ cause })), ); @@ -934,6 +946,9 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( messageId, createdAt: DateTime.formatIso(yield* DateTime.now), ...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}), + ...(waitForCompletion && process.env.T3CODE_THREAD_ID?.trim() + ? { rejectWaitForThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) } + : {}), }, ); }), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index cd25503157fe..17925ca6d6be 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -798,6 +798,11 @@ describe("ProviderRuntimeIngestion", () => { state: "cancelled" as const, suffix: "cursor", }, + { + provider: ProviderDriverKind.make("codex"), + state: "aborted" as const, + suffix: "codex-aborted", + }, ]; for (const [index, entry] of cases.entries()) { @@ -853,15 +858,27 @@ describe("ProviderRuntimeIngestion", () => { turnId, createdAt, }); - harness.emit({ - type: "turn.completed", - eventId: asEventId(`evt-turn-completed-${entry.suffix}`), - provider: entry.provider, - threadId, - turnId, - payload: { state: entry.state }, - createdAt, - }); + harness.emit( + entry.state === "aborted" + ? { + type: "turn.aborted", + eventId: asEventId(`evt-turn-aborted-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + payload: { reason: "interrupted" }, + createdAt, + } + : { + type: "turn.completed", + eventId: asEventId(`evt-turn-completed-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + payload: { state: entry.state }, + createdAt, + }, + ); await harness.drain(); expect(await harness.readTurnRequestWaitState(threadId, messageId)).toEqual({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d8c6bbc9b7fc..6aab0a2bd417 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1540,6 +1540,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1570,7 +1571,8 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + event.type === "turn.aborted" ) { const status = (() => { switch (event.type) { @@ -1590,6 +1592,8 @@ const make = Effect.gen(function* () { ? "interrupted" : "ready"; } + case "turn.aborted": + return "interrupted"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -1600,7 +1604,9 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1844,7 +1850,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (event.type === "turn.completed" || event.type === "turn.aborted") { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; From f60fe4ab3e4af34cc7863c123956cb646f0273d0 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 23 Aug 2026 23:34:55 -0700 Subject: [PATCH 20/23] fix(lastcode): settle abandoned thread waits --- .../updateDrain/UpdateDrainAdmission.test.ts | 13 ++++++++++- .../src/updateDrain/UpdateDrainAdmission.ts | 22 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/server/src/updateDrain/UpdateDrainAdmission.test.ts b/apps/server/src/updateDrain/UpdateDrainAdmission.test.ts index 3ab6a12cbd88..abeb5197c654 100644 --- a/apps/server/src/updateDrain/UpdateDrainAdmission.test.ts +++ b/apps/server/src/updateDrain/UpdateDrainAdmission.test.ts @@ -18,9 +18,11 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRequestCorrelationRepositoryLive } from "../persistence/Layers/ProjectionTurnRequestCorrelations.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { ProjectionTurnRepositoryLive } from "../persistence/Layers/ProjectionTurns.ts"; import { UpdateDrainRepositoryLive } from "../persistence/Layers/UpdateDrainRepository.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../persistence/Services/ProjectionTurnRequestCorrelations.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { TerminalManager } from "../terminal/Manager.ts"; import { layer as updateDrainLayer } from "./UpdateDrain.ts"; @@ -108,6 +110,7 @@ const makeHarness = Effect.fn("UpdateDrainAdmissionTest.makeHarness")(function* const terminals = yield* Ref.make>([]); const dependencies = Layer.mergeAll( durableLayer, + ProjectionTurnRequestCorrelationRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)), ProjectionTurnRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)), Layer.mock(ProjectionSnapshotQuery)({ getShellSnapshot: () => Ref.get(shell) }), Layer.mock(TerminalManager)({ @@ -171,13 +174,16 @@ it.effect("ignores pending starts left behind by a previous server lifetime", () yield* Effect.gen(function* () { const projectionTurns = yield* ProjectionTurnRepository; + const correlations = yield* ProjectionTurnRequestCorrelationRepository; + const messageId = MessageId.make("message-stale-pending-turn"); yield* projectionTurns.replacePendingTurnStart({ threadId, - messageId: MessageId.make("message-stale-pending-turn"), + messageId, sourceProposedPlanThreadId: null, sourceProposedPlanId: null, requestedAt: now, }); + yield* correlations.insertPending({ threadId, messageId, requestedAt: now }); const admission = yield* makeUpdateDrainAdmission(); yield* admission.dispatch({ type: "update-drain.start", @@ -192,6 +198,11 @@ it.effect("ignores pending starts left behind by a previous server lifetime", () (yield* admission.claimActivation({ requestId })).commandType, "update-drain.claim", ); + const correlation = yield* correlations.get({ threadId, messageId }); + assert.equal(correlation._tag, "Some"); + if (correlation._tag === "Some") { + assert.equal(correlation.value.state, "interrupted"); + } }).pipe(Effect.provide(harness.dependencies)); }), ); diff --git a/apps/server/src/updateDrain/UpdateDrainAdmission.ts b/apps/server/src/updateDrain/UpdateDrainAdmission.ts index 028410650ae1..9e1af2529685 100644 --- a/apps/server/src/updateDrain/UpdateDrainAdmission.ts +++ b/apps/server/src/updateDrain/UpdateDrainAdmission.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Semaphore from "effect/Semaphore"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../persistence/Services/ProjectionTurnRequestCorrelations.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { TerminalManager } from "../terminal/Manager.ts"; import { UpdateDrain } from "./UpdateDrain.ts"; @@ -72,16 +73,33 @@ export const makeUpdateDrainAdmission = Effect.fn("makeUpdateDrainAdmission")(fu const drain = yield* UpdateDrain; const projections = yield* ProjectionSnapshotQuery; const projectionTurns = yield* ProjectionTurnRepository; + const turnRequestCorrelations = yield* ProjectionTurnRequestCorrelationRepository; const terminals = yield* TerminalManager; const mutex = yield* Semaphore.make(1); // The provider event stream is hot, so accepted starts from a previous // server lifetime cannot be resumed. Keep their exact identities out of the // live blocker set; a new start replaces the row with a new message id. + const stalePendingTurnStarts = yield* projectionTurns + .listPendingTurnStarts() + .pipe(Effect.mapError(internalError)); const stalePendingTurnStartKeys = new Set( - (yield* projectionTurns.listPendingTurnStarts().pipe(Effect.mapError(internalError))).map( - (pending) => pendingTurnStartKey(pending.threadId, pending.messageId), + stalePendingTurnStarts.map((pending) => + pendingTurnStartKey(pending.threadId, pending.messageId), ), ); + const restartedAt = DateTime.formatIso(yield* DateTime.now); + yield* Effect.forEach( + stalePendingTurnStarts, + (pending) => + turnRequestCorrelations.resolve({ + threadId: pending.threadId, + messageId: pending.messageId, + turnId: null, + state: "interrupted", + resolvedAt: restartedAt, + }), + { discard: true }, + ).pipe(Effect.mapError(internalError)); const currentBlockers = Effect.fn("UpdateDrainAdmission.currentBlockers")(function* () { // Read pending starts first. If one transitions while the shell snapshot is From 8bd28429c4fdb9a326309e264ea2d0f0aa809bf5 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 23 Aug 2026 23:41:41 -0700 Subject: [PATCH 21/23] test(lastcode): verify aborted wait finalization --- .../orchestration/Layers/ProviderRuntimeIngestion.test.ts | 8 ++++++++ docs/user/codex-thread-tools.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 17925ca6d6be..6f5138034330 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -886,6 +886,14 @@ describe("ProviderRuntimeIngestion", () => { state: "interrupted", turnId, }); + expect( + (await harness.readEvents()).some( + (event) => + event.type === "thread.turn-assistant-finalized" && + event.payload.threadId === threadId && + event.payload.turnId === turnId, + ), + ).toBe(true); } }); diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md index a0158aedd244..7815e006fb09 100644 --- a/docs/user/codex-thread-tools.md +++ b/docs/user/codex-thread-tools.md @@ -34,6 +34,8 @@ oversized messages, missing or ambiguous targets, authorization failures, and re fail without reporting acceptance. Add `--wait` when the caller needs the exact resulting turn rather than dispatch acceptance. +`send --wait` cannot target the caller's current thread because that queued turn cannot begin +until the current command returns; use plain `send` for a self-directed follow-up. LastCode emits one `LASTCODE_WAIT_HANDLE=` recovery line on stderr before the long wait, then prints one final JSON result on stdout. A completed result includes the exact turn ID and a response bounded to 64,000 characters. Timeouts do not interrupt the target; From f8c41d39ff42f3bac001042a748ed03e486a514a Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 24 Aug 2026 00:40:39 -0700 Subject: [PATCH 22/23] fix(lastcode): reject pending self waits --- apps/server/src/cli/thread.test.ts | 28 ++++++++++++++++++++++++++++ apps/server/src/cli/thread.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index a580cc075f0b..7eb2172e8291 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -28,6 +28,7 @@ import { currentThreadOutput, listThreadsOutput, isAuthoritativeDispatchFailure, + isPendingCurrentThreadWait, readThreadOutput, retryAmbiguousTrackedDispatch, resolveThreadTarget, @@ -63,6 +64,33 @@ it.effect("does not retry an authoritative tracked dispatch rejection", () => }), ); +it("rejects only pending standalone waits for the current thread", () => { + const waitHandle = { + kind: "wait-handle" as const, + environmentId: EnvironmentId.make("env-runner"), + threadId: ThreadId.make("thread-runner"), + messageId: MessageId.make("message-wait"), + }; + assert.isTrue( + isPendingCurrentThreadWait(waitHandle, { kind: "timed-out", waitHandle }, "thread-runner"), + ); + assert.isFalse( + isPendingCurrentThreadWait( + waitHandle, + { + kind: "interrupted", + environmentId: EnvironmentId.make("env-runner"), + threadId: ThreadId.make("thread-runner"), + messageId: MessageId.make("message-wait"), + }, + "thread-runner", + ), + ); + assert.isFalse( + isPendingCurrentThreadWait(waitHandle, { kind: "timed-out", waitHandle }, "thread-other"), + ); +}); + const shellThread = (id: string) => ({ id: ThreadId.make(id) }) as OrchestrationThreadShell; const activity = (id: string, summary: string, createdAt: string) => diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 60b66f093358..8f1dc2026280 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -16,6 +16,7 @@ import { type OrchestrationThreadShell, ThreadId, ThreadWaitHandle, + type ThreadWaitResult, } from "@t3tools/contracts"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; @@ -1032,6 +1033,8 @@ const runThreadWait = Effect.fn("runThreadWait")(function* ( ), }); } + const currentThreadId = process.env.T3CODE_THREAD_ID?.trim(); + const waitingOnCurrentThread = currentThreadId === waitHandle.threadId; return yield* Effect.gen(function* () { const auth = yield* EnvironmentAuth.EnvironmentAuth; const result = yield* withReadSession(auth, (token) => @@ -1039,14 +1042,25 @@ const runThreadWait = Effect.fn("runThreadWait")(function* ( client.orchestration .waitThread({ headers: { authorization: `Bearer ${token}` }, - payload: { waitHandle, timeoutMs }, + payload: { waitHandle, timeoutMs: waitingOnCurrentThread ? 1 : timeoutMs }, }) - .pipe(Effect.timeout(`${timeoutMs + 5_000} millis`)), + .pipe(Effect.timeout(`${(waitingOnCurrentThread ? 1 : timeoutMs) + 5_000} millis`)), ), ); if (result._tag === "Failure" && isAuthoritativeWaitFailure(result.failure)) { return yield* new ThreadCliError({ operation: "live wait", cause: result.failure }); } + if ( + result._tag === "Success" && + isPendingCurrentThreadWait(waitHandle, result.success, currentThreadId) + ) { + return yield* new ThreadCliError({ + operation: "live wait", + cause: new Error( + "Cannot wait for a pending request in the current thread because its queued turn cannot start until this command exits.", + ), + }); + } yield* Console.log( yield* encodeJson( result._tag === "Success" ? result.success : { kind: "transport-unknown", waitHandle }, @@ -1062,6 +1076,14 @@ const runThreadWait = Effect.fn("runThreadWait")(function* ( ); }); +export function isPendingCurrentThreadWait( + waitHandle: ThreadWaitHandle, + result: ThreadWaitResult, + currentThreadId: string | undefined, +) { + return currentThreadId === waitHandle.threadId && result.kind === "timed-out"; +} + const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Print stable JSON output."), Flag.withDefault(false), From 664512fec4aeb9c3119a97ea33eee2b4c975ceda Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 24 Aug 2026 01:03:41 -0700 Subject: [PATCH 23/23] fix(lastcode): preserve checkpoint assistant replies --- apps/server/src/bin.test.ts | 20 ++++++++- .../Layers/TurnRequestWaitQuery.test.ts | 44 ++++++++++++++++++- .../Layers/TurnRequestWaitQuery.ts | 22 ++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index d8137a6700e5..08566eb21d01 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1159,6 +1159,24 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }, createdAt: emptyAt, }); + const checkpointAssistantMessageId = MessageId.make("message-checkpoint-real-assistant"); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-checkpoint-real-assistant-delta"), + threadId, + messageId: checkpointAssistantMessageId, + delta: "actual checkpoint response", + turnId: checkpointTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-checkpoint-real-assistant-complete"), + threadId, + messageId: checkpointAssistantMessageId, + turnId: checkpointTurnId, + createdAt: emptyAt, + }); const syntheticAssistantMessageId = MessageId.make(`assistant:${checkpointTurnId}`); yield* engine.dispatch({ type: "thread.turn.diff.complete", @@ -1198,7 +1216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { kind: "terminal", state: "completed", turnId: checkpointTurnId, - response: "", + response: "actual checkpoint response", }, ); const bufferedMessageId = MessageId.make("message-short-buffered-request"); diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts index 1f547b82dd84..b4dd18bb4dc8 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.test.ts @@ -1,4 +1,4 @@ -import { TurnId } from "@t3tools/contracts"; +import { MessageId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { resolveTurnRequestWaitState } from "./TurnRequestWaitQuery.ts"; @@ -10,6 +10,7 @@ const interruptedRow = { assistantMessageId: null, response: null, responseStreaming: null, + latestFinalizedAssistantResponse: null, assistantFinalizedAt: null, sessionStatus: "running" as const, sessionActiveTurnId: TurnId.make("turn-interrupt-wait"), @@ -33,3 +34,44 @@ it("settles after the provider session confirms interruption", () => { }, ); }); + +it("uses a finalized assistant row when a checkpoint placeholder replaced its id", () => { + assert.deepEqual( + resolveTurnRequestWaitState({ + ...interruptedRow, + turnId: TurnId.make("turn-checkpoint-replaced"), + turnState: "completed", + assistantMessageId: MessageId.make("assistant:turn-checkpoint-replaced"), + latestFinalizedAssistantResponse: "actual assistant response", + assistantFinalizedAt: "2026-08-24T08:00:00.000Z", + sessionStatus: "ready", + sessionActiveTurnId: null, + }), + { + kind: "terminal", + state: "completed", + turnId: TurnId.make("turn-checkpoint-replaced"), + response: "actual assistant response", + }, + ); +}); + +it("keeps a finalized message-free checkpoint response empty", () => { + assert.deepEqual( + resolveTurnRequestWaitState({ + ...interruptedRow, + turnId: TurnId.make("turn-checkpoint-empty"), + turnState: "completed", + assistantMessageId: MessageId.make("assistant:turn-checkpoint-empty"), + assistantFinalizedAt: "2026-08-24T08:00:00.000Z", + sessionStatus: "ready", + sessionActiveTurnId: null, + }), + { + kind: "terminal", + state: "completed", + turnId: TurnId.make("turn-checkpoint-empty"), + response: "", + }, + ); +}); diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts index 5a04054a9731..126500f40d52 100644 --- a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -19,6 +19,7 @@ const WaitRow = Schema.Struct({ assistantMessageId: Schema.NullOr(MessageId), response: Schema.NullOr(Schema.String), responseStreaming: Schema.NullOr(Schema.Number), + latestFinalizedAssistantResponse: Schema.NullOr(Schema.String), assistantFinalizedAt: Schema.NullOr(Schema.String), sessionStatus: Schema.NullOr( Schema.Literals(["idle", "starting", "running", "ready", "interrupted", "stopped", "error"]), @@ -49,6 +50,14 @@ export const resolveTurnRequestWaitState = (value: typeof WaitRow.Type): TurnReq if (value.assistantMessageId !== MessageId.make(`assistant:${value.turnId}`)) { return { kind: "pending" }; } + if (value.latestFinalizedAssistantResponse !== null) { + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: value.latestFinalizedAssistantResponse, + }; + } return { kind: "terminal", state: "completed", turnId: value.turnId, response: "" }; } if (value.responseStreaming !== 0) { @@ -74,6 +83,7 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", messages.text AS "response", messages.is_streaming AS "responseStreaming", + latest_finalized_assistant.text AS "latestFinalizedAssistantResponse", finalizations.finalized_at AS "assistantFinalizedAt", sessions.status AS "sessionStatus", sessions.active_turn_id AS "sessionActiveTurnId" FROM projection_turn_request_correlations AS correlations @@ -81,6 +91,18 @@ export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id LEFT JOIN projection_thread_messages AS messages ON messages.message_id = turns.assistant_message_id + LEFT JOIN projection_thread_messages AS latest_finalized_assistant + ON latest_finalized_assistant.message_id = ( + SELECT candidate.message_id + FROM projection_thread_messages AS candidate + WHERE candidate.thread_id = correlations.thread_id + AND candidate.turn_id = correlations.turn_id + AND candidate.role = 'assistant' + AND candidate.is_streaming = 0 + AND candidate.message_id != ('assistant:' || correlations.turn_id) + ORDER BY candidate.updated_at DESC, candidate.created_at DESC, candidate.message_id DESC + LIMIT 1 + ) LEFT JOIN projection_turn_assistant_finalizations AS finalizations ON finalizations.thread_id = correlations.thread_id AND finalizations.turn_id = correlations.turn_id